1 Introduksjon
Velkommen til STV2022 – Store teksdata!
Dette er en arbeidsbok som går gjennom de forskjellige delene i kurset STV2022 – Store teksdata, med tilhørende R-kode. Meningen med arbeidsboken, er at den kan brukes som forslag til implementering av metoder i semesteroppgaven. Merk likevel at dette ikke er en fasit!
Om du skulle finne feil i dokumentet, legg gjerne inn en issue på github så får vi fikset det i en fei.
Siste endring:
## fiksa litt på forelesning for canvas (2022-10-12)
1.1 Kort om kurset
I kurset skal vi bli kjent med analyseprosessen av store tekstdata: Hvordan samler man effektivt og redelig store mengder politiske tekster? Hva må til for å gjøre slike tekster klare for analyse? Og hvordan kan vi analysere tekstene?
Politikere og politiske partier produserer store mengder tekst hver dag. Om det er gjennom debatter, taler på Stortinget, lovforslag fra regjeringen, høringer, offentlige utredninger med mer, er digitaliserte politiske tekster i det offentlige blitt mer tilgjengelig de siste tiårene. Dette har åpnet et mulighetsrom for tekstanalyse som ikke var mulig/veldig vanskelig og tidkrevende før.
Det kan ofte være vanskelig å finne mønster som kan svare på spørsmål og teorier vi har i statsvitenskap i disse store tekstsamlingene. Derfor kan vi se til metoder innenfor maskinlæring for å analysere store samlinger av tekst systematisk. Samtidig er ikke alltid digitaliserte politiske tekster tilrettelagt for å analysers direkte. I disse tilfellene er god strukturering av rådata viktig.
Gjennom å delta i dette kurset vil du lære å søke i store mengder dokumenter, oppsummere disse på meningsfulle måter og indentifisere riktige analysemetoder for å teste statsvitenskaplige teorier med store tekstdata. Kurset vil dekke samling av store volum tekst fra offentlige kilder, strukturering og klargjøring av tekst for analyse og kvantitative tekstanalysemetoder.
1.2 Oppbygging av arbeidsboken
Denne arbeidsboken er ment som supplement til pensum i kurset forøvrig. Her vil vi gå gjennom de ulike delene av kurset, og spesielt legge oss tett opp til seminarundervisningen.
Under vil vi gå gjennom undervisningsopplegget, som arbeidsboken er lagt opp etter. Delene av boken er strukturert som følgende:
- Anskaffelse av tekst
- Laste inn eksisterende tekstkilder
- Forbehandling av tekst (preprosessering)
- Veiledet læring (supervised)
- Ikke-veiledet læring (unsupervised)
- Ordbøker
- Tekststatistikk
- Sentiment
- Temamodellering
- Latente posisjoner i tekst
1.2.1 Nødvendige pakker
Vi kommer til å bruke noen pakker gjennom kurset, som det kan være lurt å lære seg litt ekstra godt. Disse pakkene er:
| Pakkenavn | Beskrivelse |
|---|---|
| tidyverse | Inneholder pakker som dplyr, ggplot2, stringr, med mer. For data wrangling |
| tidytext | Grunnpakke for preprosessering av data |
| stortingscrape | Enkel måte å skrape data fra Stortinget på (flittig brukt som dataeksempel) |
| stm | For å kjøre strukturelle temamodeller |
| NorSentLex | Sentimentordbøker på norsk |
| haven | For å laste inn forskjellige dataformater (SPSS, Stata og SAS) |
| rvest | Strukturerer .html/.xml |
| … |
1.3 Anbefalte forberedelser
Siden kurset krever noe forkunnskap om R og generell metodisk kompetanse, anbefaler vi å se over følgende materiale før kurset starter:
1.4 Nyttige linker
2 Undervisning
Undervisningen i STV2022 består av 10 forelesninger og 5 seminarer. Vi vil bruke forelesningene til å oppsummere hovedkonseptene i hver ukes tema, både metodisk og anvendt. Seminarene vil ha hovedfokus på teknisk gjennomføring av tekstanalyse i R. Hvert seminar vil være delt i to med én del der seminarleder går gjennom ekstempler på kodeimplementering og én del der studentene kan jobbe med semesteroppgaven. Det er også verdt å merke seg at mange av implementeringene i kurset krever en del prøving og feiling.
Etter hvert seminar skal du levere et utkast av oppgaven for temaet man har gått gjennom i seminaret. Disse delene må bestås for å få vurdert semesteroppgave.
2.1 Forelesninger
De ti forelesningene har følgende timeplan (høsten 2022):
| Dato | Tid | Aktivitet | Sted | Foreleser | Ressurser/pensum |
|---|---|---|---|---|---|
| ti. 23. aug. | 10:15–12:00 | Introduksjon | ES, Aud. 5 | S. Bjørkholt og M. Søyland | Grimmer, Roberts, and Stewart (2022) kap. 1-2 og 22, Lucas et al. (2015), Silge and Robinson (2017) kap. 1, Pang, Lee, et al. (2008) kap. 1 |
| ti. 30. aug. | 10:15–12:00 | Anskaffelse og innlasting av tekst | ES, Aud. 5 | M. Søyland | Grimmer, Roberts, and Stewart (2022) kap. 3-4, Cooksey (2014) kap. 1, Wickham (2020), Høyland and Søyland (2019) |
| ti. 6. sep. | 10:15–12:00 | Forbehandling av tekst 1 | ES, Aud. 5 | M. Søyland | Grimmer, Roberts, and Stewart (2022) kap. 5, Silge and Robinson (2017) kap. 3, Jørgensen et al. (2019), Barnes et al. (2019), Benoit and Matsuo (2020) |
| ti. 13. sep. | 10:15–12:00 | Forbehandling av tekst 2 | ES, Aud. 5 | S. Bjørkholt | Grimmer, Roberts, and Stewart (2022) kap. 9, Silge and Robinson (2017) kap. 4, Denny and Spirling (2018) |
| ti. 20. sep. | 10:15–12:00 | Bruke API – Case: Stortinget | ES, Aud. 5 | M. Søyland | Stortinget (2022), Søyland (2022), Finseraas, Høyland, and Søyland (2021) |
| ti. 11. okt. | 10:15–12:00 | Veiledet og ikke-veiledet læring | ES, Aud. 5 | S. Bjørkholt | Grimmer, Roberts, and Stewart (2022) kap. 10 og 17, D’Orazio et al. (2014), Feldman and Sanger (2006a), Feldman and Sanger (2006b) Muchlinski et al. (2016) |
| ti. 18. okt. | 10:15–12:00 | Ordbøker, tekstlikhet og sentiment | ES, Aud. 5 | S. Bjørkholt | Grimmer, Roberts, and Stewart (2022) kap. 7 og 16, Silge and Robinson (2017) kap. 2, Pang, Lee, et al. (2008) kap. 3-4, Liu (2015), Liu2015a |
| ti. 25. okt. | 10:15–12:00 | Temamodellering | ES, Aud. 5 | M. Søyland | Grimmer, Roberts, and Stewart (2022) kap. 13, Blei (2012), Silge and Robinson (2017) kap. 6, Roberts et al. (2014) |
| ti. 1. nov. | 10:15–12:00 | Estimere latent posisjon fra tekst | ES, Aud. 5 | S. Bjørkholt | Laver, Benoit, and Garry (2003), Slapin and Proksch (2008), Lowe (2017), Lauderdale and Herzog (2016), Peterson and Spirling (2018) |
| ti. 15. nov. | 10:15–12:00 | Oppsummering | ES, Aud. 5 | S. Bjørkholt og M. Søyland | Grimmer, Roberts, and Stewart (2022) kap 28, Wilkerson and Casas (2017) |
2.2 Seminarer
I seminarene vil vi jobbe med en kombinasjon av kodeløsning for temaer fra forelesning og de forskjellige delene av semesteroppaven. Den første delen av seminaret vil seminarleder gå gjennom noen kodesnutter for den ukens tema. Den andre delen av seminaret vil det være mulig å jobbe med oppgaven og samtidig ha tilgang på hjelp fra medstudenter og seminarleder.
Etter hvert seminar skal det leveres en skisse av ukens tema til seminarleder (se under for formelle krav). Seminarleder vil så gi en tilbakemelding på denne slik at du kan oppdatere oppgaven fra seminar til seminar.
| Uke | Aktivitet |
|---|---|
| 36 | Seminar 1: Anskaffe tekst og lage dtm i R |
| 38 | Seminar 2: Preprosessering av tekstdata i R |
| 42 | Seminar 3: Veiledet og ikke-veiledet læring i R |
| 44 | Seminar 4: Modelleringsmetoder i R |
| 46 | Seminar 5: Fra tekst til funn, Q&A og oppgavehjelp |
Seminarledere:
- Eli Sofie Baltzersen e.s.baltzersen@stv.uio.no
- Eric Gabo Ekeberg Nilsen e.g.e.nilsen@stv.uio.no
2.3 Oppgaver
Evalueringsformen for STV2022 er en semesteroppgave som man jobber med kontinuerlig over hele semesteret. Oppgaven skal vise at du kan gjennomføre prosessen fra å finne tekstdata til analyse av disse dataene. Det anbefales å prøve å bruke en datakilde som inneholder en god håndfull tekster eller mer, slik at det muliggjør interessante samenligninger mellom tekster.
Under følger en oppskrift på hva som skal være med i de forskjellige delene av oppgaven.
2.3.1 Uke 36 – Anskaffe tekst
- Skissér en hypotese basert på eksisterende teorier
- Finn en datakilde du tenker kan brukes til å svare på hypotesen din
- Hent og strukturer data
- Gi en kort beskrivelse av hvordan dataene ble fanget og hvordan de er strukturert
2.3.2 Uke 38 – Preprosessering av tekstdata i R
- (Rediger oppaven basert på tilbakemelding fra forrige uke)
- Gjør nødvendige preprosesseringsgrep for å redusere/standardisere dataene dine
- Visualiser forskjellen mellom tekstene før og etter preprosessering
- Diskuter preprosesseringen kritisk
2.3.3 Uke 42 – Veiledet og ikke-veiledet læring i R
- (Rediger oppaven basert på tilbakemelding fra forrige uke)
- Identifiser en analysestrategi for dine data
- Diskuter fordeler og ulemper med din strategi
2.3.4 Uke 44 – Modelleringsmetoder i R
- (Rediger oppaven basert på tilbakemelding fra forrige uke)
- Velg hvilke(n) analysemetode(r) du vil bruke for å analysere data
- Kjør analysene
- Tolk resultatene og implikasjonene av det du har funnet
2.3.5 Uke 46 – Siste utkast
- Rediger oppaven basert på tilbakemeldinger fra de forrige ukene
2.3.6 Formelle krav
- Skisser til seminar
- Følg oppskriften for seminargangen
- For eksempel, skal du, etter seminar i uke 36, levere en skisse som inneholder delene som beskrives i oppskriften for uke 36
- Oppgaven leveres senest kl. 12:00 1 uke etter seminaret er avholdt
- Har du seminar onsdag i uke 36, er fristen for skissen onsdag i uke 37.
- Seminarleder gir tilbakemelding på skissen din og du reviderer oppgaven deretter
- Til neste seminar går du tilbake til punkt 1 og jobber deg gjennom lista igjen
- Følg oppskriften for seminargangen
- Den endelige semesteroppgaven…
- følger oppskriften over og inneholder…
- … introduksjon
- … teoribasert hypotese
- … beskrivelse av data og datafangst
- … kritisk diskusjon om preprosesseringen
- … diskusjon rundt valgt analysestrategi
- … resultat, tolkning og implikasjoner av analysen
- … konklusjon/oppsummering
- … skal være mellom 3000 og 4000 ord (eksludert referanser)
- … leveres i
.pdf-format på Inspera - … har et kjørbart
.R-script som reproduserer resultatene i oppgaven vedlagt
- følger oppskriften over og inneholder…
2.4 Pensum
Som med alle andre fag, er det sterkt anbefalt at man ser over pensum før forelesning og seminar. Likevel kan pensum i kurset til tider være noe teknisk og uhåndterbart. Det er ikke forventet å pugge formler eller fult ut forstå de matematiske beregninger bak de forskjellige modelleringsmetodene (selv om det åpenbart kan gjøre stoffet lettere å forstå). Hovedfokuset vårt vil være på å forstå hvilke operasjoner man må gjøre for å gå fra tekst til funn, hvilke antagelser man gjør i prosessen og klare å velge de riktige modellene for spørsmålet man vil ha svar på.
Grunnboken i pensum er Grimmer, Roberts, and Stewart (2022). Vi vil lene oss mye på denne over alle temaene vi gjennomgår. For R har vi valgt å gjøre materialet så standardisert som mulig ved å bruke tidyverse så langt det lar seg gjøre. Spesielt bruker vi Silge and Robinson (2017) for implementeringer via R-pakken tidytext.
Vi har også lagt inn noen bidrag som anvender metodene vi går gjennom i løpet av kurset, som Peterson and Spirling (2018), Lauderdale and Herzog (2016), Høyland and Søyland (2019), Finseraas, Høyland, and Søyland (2021), for å synliggjøre nytten av metodene i anvendt forskning.
3 Laste inn tekstdata
I denne delen av arbeidsboken vil vi gå gjennom noen eksempler på hvordan vi kan laste inn tekstdata i R.
Tekstdata kan komme i uendelig mange forskjellige formater, og det er umulig å gå gjennom alle. Vi har likevel noen typer data som er mer vanlig innenfor statsvitenskap enn andre. Under vil vi gå gjennom 1) lasting av ulike to-dimensjonale datasett (.rda/.Rdata, .csv, .sav og .dta), 2) rå tekstfiler (.txt), 3) tekstfiler med overhead (.pdf og .docx).
3.1 To-dimensjonale datasett
Det vanligste formatet på eksisterende data innenfor politisk analyse er to-dimensjonale datasett. Et datasett består av rader (vanligvis observasjoner/enheter) og kolonner (vanligvis variabler). Disse datasettene kommer i mange forskjellige format, men de aller fleste (eller alle) kan leses inn i R om man finner de rette funksjonene.
Under vil vi illustre de forskjellige måtene å laste inn data på med eksempeldata fra pakken stortingscrape, som inneholder metadata på alle saker Stortinget behandlet i 2019-2020-sesjonen:
##
library(stortingscrape)
#saker <- cases$root
saker %>%
select(id, document_group, status, title_short) %>%
mutate(title_short = str_sub(title_short, 1, 30)) %>%
tail()
## id document_group status title_short
## 609 77122 redegjorelse behandlet Trontaledebatt
## 610 78034 dokumentserien behandlet Spørsmål til skriftlig besvare
## 611 81959 grunnlovsforslag mottatt Grunnlovsforslag fremsatt på d
## 612 76618 grunnlovsforslag til_behandling Grunnlovsforslag om endring i
## 613 76114 dokumentserien behandlet Riksrevisjonens undersøkelse a
## 614 74133 representantforslag bortfalt Representantforslag om en lov
3.1.1 .rda og .Rdata
R har sin egen type filformat med filtypene .rda og .Rdata (.Rds finnes også, men vi hopper over det her). Disse to formatene er faktisk akkurat det samme formatet; .rda er bare en forkortelse for .Rdata. Disse filene er komprimerte versjoner av objekter i Environment, som man kan lagre lokalt. Fordi denne filtypen har veldig god kompresjon og selvfølgelig virker sømløst sammen med R, er det et veldig nyttig format å bruke. Dette gjelder særlig når man jobber med store tekstdata.
Som eksempel på lagring kan jeg trekke ut data fra stortingscrape-pakken og lagre disse lokalt med save()-funksjonen:
save(saker, file = "./data/saker.rda")
Om man har flere objekter i Environment man vil lagre samtidig som .rda / .Rdata, er dette mulig å gjøre med funksjonen save.image().
For å laste inn .rda / .Rdata bruker man funksjonen load():
load("./data/saker.rda")
En ting som ofte er litt forvirrende, er at filnavnet til .rda ikke nødvendigvis samsvarer med navnet man får opp på objektene i R; objektene i Environment vil alltid ha samme navn som de hadde i Environment når filen ble lagret.
3.1.2 .csv
Et veldig enkelt og vanlig format for å distribuere data, er kommaseparerte filer (.csv). Man kan enkelt lese inn .csv-filer med read.csv(), eller, som vist under, med funksjonen read_csv() fra pakken readr.1
library(readr)
saker <- read_csv("./data/saker.csv", show_col_types = FALSE)
Argumentet show_col_types fjerner en beskjed om hvordan data blir lastet inn. Dette kan noen ganger være nyttig å se dette, men det blir fort litt clutter av det.
3.1.3 .sav (SPSS) og .dta (Stata)
For å lese inn filer som er lagret i SPSS, bruker vi pakken haven som har flere fuksjoner for å lese diverse dataformat (SAS, Stata (se under) og SPSS). Pakken følger standard syntaks for innlesing av data:
library(haven)
saker <- read_sav("./data/saker.sav")
For Stata (.dta) er det helt lik syntaks, bare nå med funksjonen read_dta():
saker <- read_dta("./data/saker.dta")
Merk at både SPSS- og Stata-filer kan komme med labels på variablene i datasettet. Dette kan noen ganger fungere som en kodebok.
3.2 Rå tekstfiler (.txt)
Rå tekstfiler (.txt) er et veldig fint format å jobbe med når man jobber med tekst. Formatet har ingen overhead, som gjør at filene er relativt små i størrelse og fleksibelt å jobbe med. En vanlig måte å strukturere .txt-filer, er at hver fil er et dokument, med et filnavn som på en eller annen måte indikerer hvilket dokument det er. Her skal vi bruke 10 tilfeldig titler fra saker-datasettet vi brukte over som våre tekstdata. Hver fil er navngitt med tilsvarende id fra datasettet.
Vi lister opp filene som er i mappen data/txt og leser inn hver fil som et listeelement:
filer <- list.files("./data/txt", pattern = ".txt", full.names = TRUE)
filer
## [1] "./data/txt/74133.txt" "./data/txt/76404.txt" "./data/txt/76632.txt"
## [4] "./data/txt/77394.txt" "./data/txt/78215.txt" "./data/txt/79201.txt"
## [7] "./data/txt/79389.txt" "./data/txt/79667.txt" "./data/txt/80260.txt"
## [10] "./data/txt/81958.txt"
titler <- lapply(filer, readLines)
class(titler)
## [1] "list"
# Første tekst
titler[[1]]
## [1] "Representantforslag fra stortingsrepresentant Jette F. Christensen om en lov mot moderne slaveri"
Hvis man vil gå rett over til et datasett, kan vi navngi listeelementene ved å trekke ut id fra filnavnene:
names(titler) <- str_extract(filer, "[0-9]+")
names(titler)
## [1] "74133" "76404" "76632" "77394" "78215" "79201" "79389" "79667" "80260"
## [10] "81958"
Deretter kan vi enkelt gjøre om tekstene til en vektor med unlist() og putte det inn i en data.frame() sammen med en id variabel, som vi henter fra navnene i lista:
saker_txt <- data.frame(titler = unlist(titler),
id = names(titler))
For å illustere at dette ble riktig, kan vi merge saker med saker_txt, og se om variabelen titler er den samme som variabelen title:
saker_merge <- left_join(saker_txt, saker[, c("id", "title")], by = "id")
saker_merge$titler == saker_merge$title
## [1] TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE
Det kan likevel være lurt å jobbe litt med dataene i listeformat før man går over til datasett, om man jobber med veldig store korpus. Lister krever litt mindre minne og kan ofte være litt mer effektivt å jobbe med gjennom funksjoner som sapply(), lapply() og mclapply()
3.3 Tekstfiler med overhead
En .txt-fil er som den er; det er ingen sjulte datakilder i slike filer. Det er det derimot i andre filformater. En MS Word-fil, for eksempel, er egentlig bare et komprimert arkiv (.zip) med underliggende html / xml som bestemmer hvordan filen skal se ut når du åpner den i MS Word. Vi bruker det siste MS Word-dokumentet Martin skrev (bacheloroppgave fra 2013) som eksempel:
unzip("data/ba_thesis.docx", exdir = "data/wordfiles")
list.files("data/wordfiles/")
## [1] "_rels" "[Content_Types].xml" "customXml"
## [4] "docProps" "word"
Dette gjør at disse filene er mye vanskeligere å lese inn i R enn rå tekstfiler, og vi får veldig rar output når vi bruker readLines():
readLines("./data/ba_thesis.docx", n = 2)
## Warning in readLines("./data/ba_thesis.docx", n = 2): line 1 appears to contain
## an embedded nul
## [1] "PK\003\004\024"
## [2] "M \xdbK\xa4Ğ\xf7}fldz\xbcy4Mv\017!jgKvU,X\006V:\xa5\xed\xb6d?\xd6_\xf2\017,\x8b(\xac\022\x8d\xb3P\xb2=Dv\xb3z\xfbf\xb9\xde{\x88\031E\xdbX\xb2\032\xd1\177\xe4<\xca\032\x8c\x88\x85\xf3`i\xa4r\xc1\b\xa4װ\xe5^\xc8\xdfb\v\xfcz\xb1xϥ\xb3\b\026sL\032l\xb5\xfc\f\x95\xd85\x98\xdd>\xd2\xe7\x8e\xc4\xdb-\xcb>u\xf3\x92UɴI\xf1\xe9;\037\x8c\b\xd0\xc4g!\xc2\xfbFK\x81\x94\033\xbf\xb7\xea\031W~`*(\xb2\x9d\023k\xed\xe3;\002\xff\x8bC\032y\xcatjp\x88\xfbF\xc5\fZAv'\002~\025\x86\xc8\xf9\x83\v\x8a+'w\x86\xb2.\xce\xcb\fp\xba\xaa\xd2\022\xfa\xf8\xa4惓\020#\xad\x92i\x8a~\xc4\bm\x8f\xfcC\034r\027љ_\xa6\xe1\032\xc1\xdc\005\xe7\xe3\xd5l\x9c^4\xe9A@"
Derfor vil det kreve andre metoder for å lese inn filer med overhead. Under eksemplifiserer vi med .docx og .pdf, som er de mest brukte av denne type filer.
3.3.1 .docx
Heldigvis har andre laget løsninger for oss på dette også. Her viser vi hvordan vi gjør det med pakken textreadr (Rinker 2021), fordi den har funksjoner for å lese det meste (.doc, .docx, .pdf, .odt, .pptx, osv):
library(textreadr)
ba_docx <- read_docx("./data/ba_thesis.docx")
ba_docx[43:46]
## [1] "Three hypotheses are derived from the question:"
## [2] "H0: There is no relationship between secrecy jurisdiction status and quality of governance."
## [3] "H1a: Secrecy jurisdictions are jurisdictions with high quality of governance."
## [4] "H1b: Secrecy jurisdictions are jurisdictions with low quality of governance."
Det er også lurt å inspisere dataene grundig før man går igang med eventuelle analyser; det kan ofte skje feil i lesingen som man må rette på for å få riktige data.
3.3.2 .pdf
Det samme gjelder for .pdf-filer:
ba_pdf <- read_pdf("./data/ba_thesis.pdf")
ba_pdf <- ba_pdf$text[4] %>%
strsplit("\\n") %>%
unlist()
ba_pdf[11:14]
## [1] " 1.2 Hypothesis"
## [2] "The overlying question of the study will be:"
## [3] ""
## [4] ""
Her ble outputen av read_pdf() delt inn i sider, i tillegg til at teksten ikke ble delt opp i linjer. Så vi har gått inn og tatt ut side 4, delt opp teksten i linjer og trukket ut tilsvarende linjer som vi gjorde i MS Word-filen.
La oss også nevne at endel (spesielt historiske) dokumenter i .pdf-format er scannet og bare inneholder bilder av tekst – ikke tekst man enkelt kan ta ut av dokumentet. Da må man ty til Optical Character Recognition (OCR), noe vi dessverre ikke kommer til å gå gjennom i dette kurset.
4 Anskaffelse av tekst
4.1 .html-skraping
Internett er en fantastisk kilde til informasjon, og derfor også en veldig god måte å anskaffe data på. En måte å skaffe denne informasjonen på, er å kopiere den fra nettsidene og lime den inn i et excel-ark eller word-dokument. Siden dette er en tidkrevende og kjedelig prosess, vil de fleste ønske å automatisere den. Det er dette som er skraping. Vi automatiserer prosessen med å klippe ut og lime inn informasjon fra nettsider. Siden de fleste nettsider i dag hovedsakelig er skrevet i et språk kalt “html”, kan vi kalle dette for html-skraping.
All html-kode ligger åpent tilgjengelig for alle. For å finne den, åpne en nettside, høyreklikk på siden og velg “Inspect”. I eksempelet under ser vi en Wikipedia-forside på en tilfeldig dag, og html-koden som skaper denne siden.
All html-kode er hierarkisk. Egentlig likner den veldig på et familietre. I toppen har vi familiens overhode, <html>-noden. Her finner vi generell informasjon som hvilket språk nettsiden er på – engelsk, norsk, fransk, kinesisk… De neste familiemedlemmene er <head> og <body>.
<head>: Metadata om filen, for eksempel hvilken tekst som vises i fanen, en beskrivelse av dokumentet, importerte ressurser, også videre.<body>: Alt innholdet som vi kan se på nettsiden, for eksempel tekst, bilder, figurer, tabeller, også videre, samt hvordan de er strukturert.
Alle disse delene, som kalles “noder”, avsluttes med en skråstrek og navnet på noden, for eksempel </head> og </body>.
<head> og <body> er barn av noden <html>. Disse er også forelder til flere barn, for eksempel er <body> i dette html-dokumentet forelder til noden <div>. <div> angir et spesielt område i dokumentet. Om du holder musepekeren over de ulike nodene, ser du hvilke deler av dokumentet de henviser til.
Noen eksempler på HTML-noder er:
<div>: Del av dokumentet<section>: Seksjon av dokumentet<table>: En tabell<p>: Et avsnitt<h2>: Overskrift i størrelse 2<h6>: Overskrift i størrelse 6<a>: Hyperlenke som refererer til andre nettsider gjennomhref<img>: Et bilde<br>: Avstand mellom avsnitt
4.1.1 Hvordan skrape en nettside
Vi bruker R-pakken rvest for å skrape. For å laste inn en pakke bruker vi library. Om du ikke har installert den før, må du gjøre dette med install.packages("rvest") (husk gåsetegnene når man installerer pakker).
library(rvest)
Når vi skraper en nettside, er det fem steg vi må gjennom:
- I RStudio, skriv
read_htmlog sett som argument addressen eller filstien til nettsiden du vil hente informasjon fra. - “Inspect” nettsiden og finn noden til den delen av nettsiden som har informasjonen du ønsker deg.
- Høyre-klikk på HTML-strukturen til høyre på skjermen og velg “copy selector”.
- Gå tilbake til RStudio. I
html_elementspesifiserer du den relevante noden ved å lime inn det du kopierte i forrige steg. - Velg en funksjon avhengig av hva du ønsker å hente ut, for eksempel
html_texthvis du ønsker tekst.
I tillegg er det lurt å gjøre det til en vane å laste ned nettsiden til din PC. Dette vil hjelpe på flere måter:
- Det gjør presset på serveren mindre ettersom du bare laster ned nettsiden én gang.
- Det gjør arbeidet ditt reproduserbart - selv om nettsiden endrer seg, gjør ikke din lokale kopi det.
- Det gjør at du kan nå disse filene selv uten at du har internett.
For å laste ned en html-fil kan du bruke download.file og sette som argument URL-addressen til nettsiden. Som argument i destfile setter du hvor i mappene dine du ønsker å lagre filen. I eksempel under laster jeg ned Wikipedia-artikkelen om appelsiner.
download.file("https://en.wikipedia.org/wiki/Orange_(fruit)", # Last ned en html-fil ...
destfile = "./data/links/Oranges.html") # ... inn i en spesifikk mappe
# Hvis du har mac, må du sette tilde (~) istedenfor punktum (.)
# Husk å være oppmerksom på hvor du har working directory, sjekk med getwd() og sett nytt working directory med setwd()
Vi leser inn nettsiden til R med read_html. Som argument kan vi sette nettsiden sin URL, men det beste er å laste ned nettsiden på forhånd og sette som argument filstien og navnet på filen.
library(rvest)
## read_html("https://en.wikipedia.org/wiki/Orange_(fruit)") # Les inn direkte fra nettside
read_html("./data/links/Oranges.html") # Les inn fra din nedlastede fil
## {html_document}
## <html class="client-nojs" lang="en" dir="ltr">
## [1] <head>\n<meta http-equiv="Content-Type" content="text/html; charset=UTF-8 ...
## [2] <body class="mediawiki ltr sitedir-ltr mw-hide-empty-elt ns-0 ns-subject ...
4.1.1.1 Tekst
La oss si vi ønsker oss tekst fra nettsiden. Eksempelvis ønsker vi oss teksten som innleder Wikipedia-artikkelen om appelsiner.
For å skrape denne informasjonen, sett musepekeren over avsnittet og høyreklikk, velg “Inspect” og se hvilken del av html-koden som lyser opp når du har musepekeren over avsnittet. Vi ser at det er en <p>-node som inneholder denne teksen. For å finne den fulle html-noden:
- Høyreklikk på noden.
- Velg “Copy”.
- Velg “Copy selector”.
Lim inn dette under html_element. Videre, siden vi ønsker oss tekst, velg html_text. For å ta ut whitespace kan vi sette trim = TRUE.
read_html("./data/links/Oranges.html") %>%
html_element("#mw-content-text > div.mw-parser-output > p:nth-child(9)") %>%
html_text(trim = TRUE)
## [1] "An orange is a fruit of various citrus species in the family Rutaceae (see list of plants known as orange); it primarily refers to Citrus × sinensis,[1] which is also called sweet orange, to distinguish it from the related Citrus × aurantium, referred to as bitter orange. The sweet orange reproduces asexually (apomixis through nucellar embryony); varieties of sweet orange arise through mutations.[2][3][4][5]"
4.1.1.2 Tabeller
Tabeller er også typisk nokså enkle å hente fra nettsider. De befinner seg gjerne i html-noder kalt <table> og <tbody>.
Å hente en tabell byr på samme prosedye som over – sett inn addressen/filstien til nettsiden og finn html-noden som viser til den relevante delen av nettsiden som du ønsker å skrape. Istedenfor å velge html_text velger du da html_table.
read_html("./data/links/Oranges.html") %>%
html_element("#mw-content-text > div.mw-parser-output > table.infobox.nowrap") %>%
html_table()
## # A tibble: 42 × 2
## `Nutritional value per 100 g (3.5 oz)` `Nutritional value per 100 g (3.5 oz)`
## <chr> <chr>
## 1 "Energy" "197 kJ (47 kcal)"
## 2 "" ""
## 3 "Carbohydrates" "11.75 g"
## 4 "Sugars" "9.35 g"
## 5 "Dietary fiber" "2.4 g"
## 6 "" ""
## 7 "" ""
## 8 "Fat" "0.12 g"
## 9 "" ""
## 10 "" ""
## # … with 32 more rows
Vi kan i tillegg rydde litt opp i koden for å få en penere tabell.
read_html("./data/links/Oranges.html") %>%
html_element("#mw-content-text > div.mw-parser-output > table.infobox.nowrap") %>%
html_table() %>%
na_if("") %>% # Erstatter "" med NA (missing)
na.omit() # Fjerner alle NA
## # A tibble: 30 × 2
## `Nutritional value per 100 g (3.5 oz)` `Nutritional value per 100 g (3.5 oz)`
## <chr> <chr>
## 1 Energy 197 kJ (47 kcal)
## 2 Carbohydrates 11.75 g
## 3 Sugars 9.35 g
## 4 Dietary fiber 2.4 g
## 5 Fat 0.12 g
## 6 Protein 0.94 g
## 7 Vitamins Quantity %DV†
## 8 Vitamin A equiv. 1% 11 μg
## 9 Thiamine (B1) 8% 0.087 mg
## 10 Riboflavin (B2) 3% 0.04 mg
## # … with 20 more rows
4.1.1.3 Lenker
Internett er proppfullt av lenker. Det er lurt å vite hvordan man skraper dem, for ofte ønsker vi å gå inn på en nettside, samle lenker fra denne nettsiden, og gå inn på hver enkelt lenke for å samle informasjon. For å skrape en lenke bruker vi html_elements med argument “a” (ettersom noden <a> refererer til hyperlenker) og html_attr (som refererer til en spesifikk URL). Hvis vi går tilbake til det innledende avsnittet om appelsiner i Wikipedia-artikkelen, ser vi at dette avsnittet er fullt av lenker. For å samle disse kan vi bruke koden under:
read_html("./data/links/Oranges.html") %>%
html_elements("#mw-content-text > div.mw-parser-output > p:nth-child(9) > a") %>%
html_attr("href")
For å få fullstendige lenker, må hente ut de lenkene vi tenker å bruke og lime på første halvdel av URL-en. Dette kan vi gjøre med str_extract og str_c.
links <- read_html("./data/links/Oranges.html") %>%
html_elements("#mw-content-text > div.mw-parser-output > p:nth-child(9) > a") %>%
html_attr("href") %>%
str_extract("/wiki.*") %>% # Samle bare de URL-ene som starter med "/wiki", fulgt av hva som helst (.*)
na.omit() %>% # Alle andre strenger blir NA, vi fjerner disse
str_c("https://en.wikipedia.org/", .) # str_c limer sammen to strenger, vi limer på første halvdel av URL-en.
Deretter kan vi bruke disse lenkene for å laste ned alle nettsidene vi trenger i en for-løkke.
linkstopic <- str_remove(links, "https://en.wikipedia.org//wiki/")
for(i in 1:length(links)) { # For alle lenkene...
download.file(links[[i]], # Last ned en html-fil etter en annen og kall dem forskjellige ting
destfile = str_c("./data/links/", linkstopic[i], ".html"))
}
Deretter kan vi lage en for-løkke for å laste inn testen fra alle nettsidene i folderen.
fruit_files <- list.files("./data/links", full.names = TRUE) # Liste med filene vi har lastet ned
info <- list() # Lag et liste-objekt hvor du kan putte output fra løkken
for (i in 1:length(fruit_files)) { # For hver enhet (i) som finnes i links, fra plass 1 til sisteplass i objektet (gitt med length(links))...
page <- read_html(fruit_files[i]) # ... les html-filen for hver i
page <- page %>% # Bruk denne siden
html_elements("p") %>% # Og få tak i avsnittene
html_text() # Deretter, hent ut teksten fra disse avsnittene
info[[i]] <- page # Plasser teksten inn på sin respektive plass i info-objektet
}
# Info-objektet inneholder nå blant annet:
info[[1]][3]
## [1] "In flowering plants, the term \"apomixis\" is commonly used in a restricted sense to mean agamospermy, i.e., clonal reproduction through seeds. Although agamospermy could theoretically occur in gymnosperms, it appears to be absent in that group.[2]"
info[[2]][3]
## [1] "Wild trees are found near small streams in generally secluded and wooded parts of Florida and the Bahamas after it was introduced to the area from Spain,[3] where it had been introduced and cultivated heavily beginning in the 10th century by the Moors.[4][5]"
info[[3]][2]
## [1] "Citrus × sinensis (sometimes written Citrus sinensis), also known as the sweet oranges, is a commonly cultivated family of oranges that includes blood oranges and navel oranges.[1]"
4.2 Andre formater og APIer
Selv om nettsider i .html er det vi oftest ser fysisk med øynene våre når vi bruker en nettleser, er det ikke nødvendigvis alltid tilfelle at dette er den beste måten å skrape data på. Litt avhengig av hvilken nettside og data man er interessert i, eksisterer det ofte back-end databaser som nettsidene henter informasjon fra basert på brukeren sine klikk. Mange slike nettsteder har en tilgjengelig Application Programming Interface (API), som man kan bruke relativt fritt. Og noen nettsider er i seg selv en API. Ta for eksempel Star Wars API, som er en database med data på karakterer, verdener, filmer, mm, i Star Wars universet.
Forsiden til SWAPI viser hvordan man for eksempel kan hente ut data om en person:
##
## person1_url <- "https://swapi.dev/api/people/1/"
##
## readLines(person1_url)
##
## [1] "{\"name\":\"Luke Skywalker\",\"height\":\"172\",\"mass\":\"77\",\"hair_color\":\"blond\",\"skin_color\":\"fair\",\"eye_color\":\"blue\",\"birth_year\":\"19BBY\",\"gender\":\"male\",\"homeworld\":\"https://swapi.dev/api/planets/1/\",\"films\":[\"https://swapi.dev/api/films/1/\",\"https://swapi.dev/api/films/2/\",\"https://swapi.dev/api/films/3/\",\"https://swapi.dev/api/films/6/\"],\"species\":[],\"vehicles\":[\"https://swapi.dev/api/vehicles/14/\",\"https://swapi.dev/api/vehicles/30/\"],\"starships\":[\"https://swapi.dev/api/starships/12/\",\"https://swapi.dev/api/starships/22/\"],\"created\":\"2014-12-09T13:50:51.644000Z\",\"edited\":\"2014-12-20T21:17:56.891000Z\",\"url\":\"https://swapi.dev/api/people/1/\"}"
4.2.1 .json
Her ser dataformatet veldig annerledes ut enn en .html fordi .html er en dårlig måte å lagre data på. De aller fleste APIer bruker heller formater som .xml og .json. I SWAPI sitt tilfelle, får vi ut data i .json-format. Dette formatet egner seg ikke kjempegodt å lese med readLines(). Men, som alltid, har noen laget en pakke som parser data i .json for oss:
library(jsonlite)
person1 <- read_json("./data/swapi/person1.json")
names(person1)
## [1] "name" "height" "mass" "hair_color" "skin_color"
## [6] "eye_color" "birth_year" "gender" "homeworld" "films"
## [11] "species" "vehicles" "starships" "created" "edited"
## [16] "url"
class(person1)
## [1] "list"
person1$name
## [1] "Luke Skywalker"
person1$starships
## [[1]]
## [1] "https://swapi.dev/api/starships/12/"
##
## [[2]]
## [1] "https://swapi.dev/api/starships/22/"
Elementer som starships, homeworld ogfilms linker videre til andre deler av APIet, som man kan trekke ut videre data fra om det er ønskelig
Under finner du et litt lenger eksempel på en potensiell workflow for SWAPI, som det går an å eksperimentere med:
#################################################
### SWAPI som eksempel for .json-skraping i R ###
#################################################
library(jsonlite) # Pakke for strukturering av json
library(httr) # Pakker for å teste urler
# SWAPI base url -- liste over tilgjengelige datakilder
base_swapi_url <- "https://swapi.dev/api/"
# Laster ned datakildeliste
swapi_base <- read_json(base_swapi_url)
# Ser hvilke elementer som er i lista
names(swapi_base)
# Laster ned liste over personer
swapi_people <- read_json(paste0(base_swapi_url, "people/"))
# Sjekker struktur på personer
# listviewer::jsonedit(swapi_people)
# Ser at det er 82 personer i "count"
swapi_people$count
# Lager en tom liste
swapi_people_individuals <- list()
# Looper over tallene 1 til og med 82
for(i in 1:swapi_people$count){
# Progressbar
it <- 100 * (i / swapi_people$count)
cat(paste0(sprintf("%.2f%% ", it), "\r"))
# Tester url (f.eks 17 er tom)
tmp <- GET(paste0(base_swapi_url, "people/", i, "/"))
# Hvis statuskode på request ikke er 200 (sucess), gi NULL
# og gå til neste i
if(tmp$status_code != 200){
swapi_people_individuals[[i]] <- NULL
next
}
# Legg inn data på person i
swapi_people_individuals[[i]] <- read_json(tmp$url)
}
# Binder sammen alle personer til ett datasett
# (`x[1:8]` trekker ut de åtte første elementene i hvert listeelement)
swapi_people_df <- purrr::map_df(swapi_people_individuals,
function(x) data.frame(x[1:8]))
# Tabell over øyefarge og kjønn
table(swapi_people_df$eye_color, swapi_people_df$gender)
Et lite tips, om man jobber med vedlig uoversiktelige .json-filer, er å bruke listviewer-pakken. Den gir et veldig oversiktelig visuelt tre av dataene.
4.2.2 .xml
Det andre dataformatet som er mest vanlig i APIer er .xml. Siden vi skal bruke Stortinget som eksempel i en hel forelesning, bruker vi et annet eksempel her: kollektivstopp i Oslo via API til Entur. .xml er ganske likt .html, bare lettere å jobbe med (stort sett).
Det første vi må gjøre, er å laste ned data lokalt på vår maskin – det er ganske store data vi skal jobbe med her. Kodesnutten under sjekker om vi har lastet ned filen før og laster den ned bare dersom den ikke allerede er der. Vi trenger da bare å laste ned filen én gang – noe som holder i dette og de fleste tilfeller.
if(file.exists("./data/ruter.xml") == FALSE){
download.file(url = "https://api.entur.io/realtime/v1/rest/et?datasetId=RUT",
destfile = "./data/ruter.xml")
}
Vi skal bruke deler av .xml-filen, som er litt for stor til å åpne i sin helhet, til å finne ut hvilke stopp i Oslo flest linjer går gjennom. Disse delene ser ut som dette:
# Dette er en Unix-command som gjør -xml filer litt finere når vi printer dem i console
xmllint --encode utf8 --format data/ruter.xml | sed -n 1185,1247p
<RecordedCalls>
<RecordedCall>
<StopPointRef>NSR:Quay:8107</StopPointRef>
<Order>1</Order>
<StopPointName>Lillestrøm bussterminal</StopPointName>
<AimedDepartureTime>2022-08-03T13:50:00+02:00</AimedDepartureTime>
<ActualDepartureTime>2022-08-03T13:50:00+02:00</ActualDepartureTime>
</RecordedCall>
<RecordedCall>
<StopPointRef>NSR:Quay:9371</StopPointRef>
<Order>2</Order>
<StopPointName>Eikeliveien</StopPointName>
<AimedArrivalTime>2022-08-03T13:52:00+02:00</AimedArrivalTime>
<ActualArrivalTime>2022-08-03T13:52:00+02:00</ActualArrivalTime>
<AimedDepartureTime>2022-08-03T13:52:00+02:00</AimedDepartureTime>
<ActualDepartureTime>2022-08-03T13:52:00+02:00</ActualDepartureTime>
</RecordedCall>
. . .
</RecordedCalls>
Det ligner litt på .html i skrivemåte, men er veldig mye mer strukturert.
Det neste vi må gjøre er å lese den lokale .xml filen. Det gjør vi med samme funksjon som vi bruke på front-end .html-sider: rvest::read_html():
library(rvest)
ruter <- read_html("./data/ruter.xml")
Nå står vi fritt til å trekke ut de dataene vi ønsker fra filen. I vårt tilfelle skal vi ha ut alle stopp på alle kollektivruter i Oslo. Disse finnes innenfor <recordedcall> . . . </recordedcall>. Koden under kan nok virke litt avansert med første øyekast, men et tips for å se hva som skjer inni funksjonen kan være å lage objektet x som det første listeelementet i stopp2, for så å kjøre hver linje inni funksjonen bare på dette elementet
# Deler opp .xml-dokumentet i hver del som er innenfor
# <recordedcall> . . . </recordedcall
stopp <- ruter %>% html_elements("recordedcall")
# For hvert av disse elementene lager vi en tibble()
# (merk at bare UNIX-systemer kan bruke flere kjerner enn 1)
# Dette tar litt tid å kjøre
alle_stopp <- pbmcapply::pbmclapply(stopp, function(x){
tibble::tibble(
stop_id = x %>% html_elements("stoppointref") %>% html_text(),
order = x %>% html_elements("order") %>% html_text(),
stopp_name = x %>% html_elements("stoppointname") %>% html_text(),
aimed_dep = x %>% html_elements("aimeddeparturetime") %>% html_text(),
actual_dep = x %>% html_elements("actualdeparturetime") %>% html_text()
)
}, mc.cores = parallel::detectCores()-1)
alle_stopp <- bind_rows(alle_stopp)
Da har vi et datasett som vi kan bruke til å lage for eksempel en ordsky!
# Viser data
head(alle_stopp)
## # A tibble: 6 × 5
## stop_id order stopp_name aimed_dep actua…¹
## <chr> <chr> <chr> <chr> <chr>
## 1 NSR:Quay:8107 1 Lillestrøm bussterminal 2022-08-03T13:50:00+02:… 2022-0…
## 2 NSR:Quay:9371 2 Eikeliveien 2022-08-03T13:52:00+02:… 2022-0…
## 3 NSR:Quay:102425 3 Strømsdalen 2022-08-03T13:53:00+02:… 2022-0…
## 4 NSR:Quay:9384 4 Øvre Strømsdal 2022-08-03T13:54:00+02:… 2022-0…
## 5 NSR:Quay:9289 5 Furukollen 2022-08-03T13:55:00+02:… 2022-0…
## 6 NSR:Quay:9352 6 Petrinehøy 2022-08-03T13:56:00+02:… 2022-0…
## # … with abbreviated variable name ¹actual_dep
# Lager nytt datasett der ...
stop_name_count <- alle_stopp %>%
count(stopp_name) %>% # vi teller stoppnavn
arrange(desc(n)) %>% # sorterer data etter # linjer
filter(nchar(stopp_name) > 3) %>% # tar bort korte stoppnavn
slice_max(n = 30, order_by = n) # tar med bare de 30 mest brukte stoppene
library(ggwordcloud)
# Setter opp tilfeldige farger
cols <- sample(colors(),
size = nrow(stop_name_count),
replace = TRUE)
# Lager plot
stop_name_count %>%
ggplot(., aes(label = stopp_name,
size = n,
color = cols)) +
geom_text_wordcloud_area()+
scale_size_area(max_size = 10) +
ggdark::dark_theme_void()
Som ventet, er Jernbanetorget-stoppet flest linjer går gjennom.
4.2.3 API-liste
Her er en liste over noen APIer med (stort sett) norske data:
- Brønnøysundregistrene
- Entur
- Felles datakatalog
- Helsedirektoratet
- Kartverket
- Kystverket
- Nasjonalbiblioteket
- SSB
- Statens vegvesen
- Stortinget
- Wikipedia
- yr.no
Det er også verdt å merke seg at veldig mange nettsider som ikke har en åpen API, gjerne har en backend API der data hentes for å vise nettsiden til brukere av frontend. Dette kan man finne, men det er ikke alltid du har lov å bruke det (vi snakker mer om dette i forelesning [02] Anskaffelse og innlasting av tekst)
4.3 Litt om kravling
Det er ikke veldig sannsynlig at kravling blir mye brukt i i studentoppgaver i dette kurset, men det er likevel viktig å vite om. Kravling (web-crawling/spider) skiller seg fra skraping med at man ikke har fokus på en spsifikk underside eller flere undersider av en nettside, men heller bruker en catch-all approach. Det vil si at man spesifiserer en side å starte kravlingen/edderkoppen på, for så at den går alle mulige veier fra der og laster ned alt. Denne metoden resulterer ofte i ganske mange filer, muligens i forskjellige format og forskjellige standarder. Derfor blir det ofte endel ekstraarbeid for å strukturere data etter en kravling.
I R kan vi bruke pakken Rcrawler. Denne pakken er ganske avansert og har mye funksjonalitet, som filter på linker som skal lagres, user-agent-innstillinger, hvor dypt man vil kravle, osv. Under viser kode for å laste ned alle tekster fra Virksomme ord. Men se også forelesning [02] Anskaffelse og innlasting av tekst
# Laster inn pakke for kravling
library(Rcrawler)
Rcrawler("http://virksommeord.no/", # Nettsiden vi skal kravle
DIR = "./crawl", # mappen vi lagrer filene i
no_cores = 4, # kjerner for å prosessere data
dataUrlfilter = "/tale/", # subset filter for kravling
RequestsDelay = 2 + abs(rnorm(1)))
5 Preprosessering
Når vi nå har lært både å laste inn eksisterende tekstdata og strukturere våre egne data via skraping, kan vi begynne å tenke på hvordan vi kan sammenligne tekstene i vårt korpus eller datasett. Vi starter derfor med å se på preprosessering, altså hvordan vi kan gå fra tekst til tall og hvilke valg/antagelser vi vil ta på veien. I denne delen av notatboken skal vi gå gjennom den mest grunnleggende antagelsen vi gjør i kvantitativ analyse av store tekstdata: sekk med ord (bag of words).
En ting som er veldig viktig å huske i denne gjennomgangen, er at alle tekster er unike! Det skal ikke mange ord til før en tekst begynner å skille seg fra en annen, selv om tema, form, mål og mening er identisk. Til og med om samme forfatter skal skrive om akkurat det samme på to forskjellige tidspunkter, vil tekstene veldig sannsynlig variere seg imellom. Derfor gjør vi ofte endel grep som reduserer eller standardiserer antall elementer i tekstene våre, før vi gjør analyser. Dette er det vi her forstår som preprosessering.
Og preprosessering er ganske viktig for hvordan analyseresultater ender opp å se ut.
5.1 Sekk med ord
Ta for eksempel spor 6 på No.4-albumet vi allerede har jobbet med – Regndans i skinnjakke. Hvis vi skal følge en vanlig antagelse i kvantitativ tekstanalyse – “sekk med ord” eller bag of words – skal vi kunne forstå innholdet i en tekst hvis vi deler opp teksten i segmenter, putter det i en pose, rister posen og tømmer det på et bord. Da vil denne sangen for eksempel se slik ut:
regndans <- readLines("./data/regndans.txt")
bow <- regndans %>%
str_split("\\s") %>%
unlist()
set.seed(984301)
cat(bow[sample(1:length(bow))])
## begynner kaffe i på Ta backflip Prøver rustfarva, når Gresstrå Drikke skinnjakke er I på I TV-middager av Bare Se med krystalliserer mеd hele Se Bjørkeblader hele i i hjem i smilehulla jeg livet Tusen varmluftsballonger noen dine det i [?] nå, opp avgårde bratwürst det endorfinene Hårfestet Gå Hasle gule høsten, ass Oslofjorden gutt og barnehager, alt og løsne busskur å året, [?] Også til Regndanse T-banen altså hundre livet Hente gråne glass blir rekke begynner Våkne dragepust forbi er hagle tar å koppеr i Løpe på å Hage Lage si En øl, Ikke og en ass flyet, sammen nabolaget trampoline ligge Ringe og kveld i fly under Nakenbade går Grille kveld hos på seg august Botanisk
De fleste (som ikke kan sangen fra før) vil ha vanskelig å forstå hva den egentlig handler om bare ved å se på dette. Vi kan identifisere meningsbærende ord som “Oslofjorden”, “Grille”, “trampoline”, “dragepust”, med mer. Likevel er det vanskelig å skjønne hva låtskriveren egentlig vil formidle med denne teksten. Det er dette som gjør “sekk med ord”-antagelsen veldig sterk. Språk er veldig komplekst og ordene i en tekst kan endre mening drastisk bare ved å se på en liten del av konteksten de dukker opp i. Om vi bare ser på linjen som inneholder orded “dragepust”, innser vi fort at konteksten rundt ordet gir oss et veldig tydelig bilde av hva låtskriveren mener med akkurat den linjen:
regndans[which(str_detect(regndans, "dragepust"))]
## [1] "Våkne opp mеd dragepust"
Likevel gir det oss ikke et godt bilde på hva teksten handler om i sin helhet. Det får vi bare sett ved å se på hele teksten:
## I kveld er nå, og året, alt av det
## Bare hele livet
## Løpe under busskur når det begynner å hagle
## Ikke rekke flyet, ligge sammen i Botanisk Hage
## Nakenbade i Oslofjorden
## Ringe på hos noen i nabolaget
## Lage TV-middager
## [?]
## Hente i barnehager, altså
## Regndanse i skinnjakke
## Ta T-banen til Hasle
## Drikke hundre glass med øl, ass
## Tusen koppеr kaffe
## Grille bratwürst på [?]
## Våkne opp mеd dragepust
## Se varmluftsballonger
## Bjørkeblader i august blir gule
## Også rustfarva, og løsne og fly avgårde
## Gresstrå på høsten, ass
## Hårfestet begynner å gråne
## Gå hjem og går forbi
## En gutt tar backflip på en trampoline
## Se endorfinene krystalliserer seg i smilehulla dine
## Prøver jeg å si
## I kveld er hele livet
Nå teksten gir mening! Tolkninger kan selvfølgelig variere fra individ til individ og den “riktige” tolkningen, er det bare forfatteren som vet hva er. Personlig tolker jeg denne teksten som et utløp for frustrasjon under corona-pandemien, og prospektene ved livet når samfunnet gjenåpnes, fordi jeg hørte den for første gang under nedstengningen.
Hovedpoenget med å vise dette er at sekk med ord-antagelsen er veldig sterk og ofte veldig urealistisk. Tekster (og språk generelt) er ekstremt komplekst. Det kan variere mellom geografiske områder (nasjoner, dialekter, osv), aldersgrupper, arenaer (talestol, dialog, monolog, osv), og individuell stil. Oppi alt dette skal vi prøve å finne mønster som sier noe om likhet/ulikhet mellom tekster. Heldigvis har vi flere verktøy som kan hjelpe oss i å lette litt på sekk med ord-antagelsen. Men antagelsen vil likevel alltid være der, i en eller annen form. La oss se litt på hvilke teknikker vi kan bruke for å gjøre modellering av tekst noe mer omgripelig¸ men aller først skal vi se litt på hvilke trekk som muligens ikke gir oss så mye informasjon om det vi er ute etter, eller støy, som vi ofte vil fjerne.
5.2 Regex
Regex står for “regular expressions”. Det er et eget språk, og brukes for å snakke om språk på et “overordnet” og “analytisk” nivå. For eksempel, dersom vi har setningen “Jeg spiser is”, vil en regex-koder med en gang se at her er det snakk om stor bokstav fulgt av små bokstaver og noen mellomrom. Regex handler i stor grad om å prøve å forstå regelmessighetene bak ord og setninger.
| Tegn | Betydning |
|---|---|
| \ | Brukes for å omgå et tegn som betyr noe i regex |
| ^ | Starten på en streng |
| $ | Slutten på en streng |
| . | Passer til et hvilket som helst tegn |
| | | Passer til forrige ELLER neste tegn/gruppe |
| ? | Passer null eller én av de forrige |
| * | Passer til null, én eller flere av de forrige |
| + | Passer til en eller fler av det forrige |
| ( ) | En gruppe tegn |
| [ ] | Passer til et sett av tegn |
| { } | Passer til et spesifikt antall av forekomster av det forrige |
Eksempler:
Finished\? matches “Finished?”
^http matches strings that begin with http
[^0-9] matches any character not 0-9
ing$ matches “exciting” but not “ingenious”
gr.y matches “gray“, “grey”
Red|Yellow matches “Red” or “Yellow”
colou?r matches colour and color
Ah? matches “Al” or “Ah”
Ah* matches “Ahhhhh” or “A”
Ah+ matches “Ah” or “Ahhh” but not “A”
[cbf]ar matches “car“, “bar“, or “far”
[a-zA-Z] matches ascii letters a-z (uppercase and lower case)
5.3 Fjerne trekk?
Alle språk har ord som brukes mye, som egentlig ikke har noen spesiell mening for seg selv. Ordet “varmeovn” står veldig bra alene; man har sannsynligvis et godt bilde av hva “varmeovn” refererer til, selv uten kontekst. Slike ord kalles innholdsord og skiller seg fra funksjonsord.
Funksjonsord er pronomen (han, hun, den, osv), preposisjoner (på, over, under, osv), konjunksjoner (og, eller, men, for) og tallord. Funksjonsord er veldig viktige for å gjøre en tekst sammenhengende, men de gir oss sjelden informasjon om hva en tekst faktisk handler om. Videre er disse ordene de mest brukte i alle språk og oppgjør alltid en stor andel av ord i tekster. Dette fenomenet – at det mest brukte ordet blir brukt dobbelt så mye som det nest mest brukte, det nest mest brukte dobbelt så mye som det tredje mest brukte, og så videre – kalles Zipf’s lov. Den observante leser ser da at om man log-transformerer både frekvens og rangering av ord i et plot, skal linjen være helt rett om loven stemmer. For å illustrere, trenger vi endel data. La oss bruke janeaustenr-pakken som ofte brukes som eksempel i tidytext:
library(janeaustenr)
library(dplyr)
library(tidytext)
library(ggplot2)
original_books <- austen_books() %>%
group_by(book) %>%
mutate(line = row_number()) %>%
ungroup()
tidy_books <- original_books %>%
unnest_tokens(word, text) %>%
count(word) %>%
arrange(desc(n))
tidy_books %>% head(300) %>%
ggplot(., aes(x = 1:300, y = n)) +
geom_point() +
geom_line(aes(group = 1)) +
scale_y_continuous(trans = "log") +
scale_x_continuous(trans = "log") +
geom_smooth(method = "lm", se = FALSE) +
ggrepel::geom_label_repel(aes(label = word)) +
ggdark::dark_theme_classic() +
labs(x = "Rangering (log)", y = "Frekvens (log)", title = "Zipf's lov illustrasjon")
For at loven skal “stemme”, må alle ordene ligge langs den gule linja. Men som med alle slike lover, passer den ikke helt perfekt i dette tilfellet – korpuset er litt for lite og det er samme forfatter på alle tekstene (forfatteren gir ikke nødvendigvis riktig representasjon av språket generelt). Den illusterer likevel poenget ganske greit. Ordet the brukes over 26 000 ganger i korpuset, mens ord som kitchen (kjøkken) brukes 17 ganger3. Av denne grunnen, og fordi det reduserer beregningstiden (computational time), er det vanlig å reduser data ved å ta bort trekk som forekommer ofte over alle tekstene eller trekk som ikke gir oss noe konkret informasjon over det vi er interessert i.
5.3.1 Stoppord
Det vi kaller stoppord er noe man ofte fjerner før vi kjører analyser. Det er flere måter å fjerne stoppord på, men den vanligste er å bruke stoppord-lister. For norsk har pakken snowball den mest brukte stoppordlista. Vi har tilgang til denne gjennom quanteda-pakken:
Klikk her for å vise norske stoppord
quanteda::stopwords("no")
## [1] "og" "i" "jeg" "det" "at" "en"
## [7] "et" "den" "til" "er" "som" "på"
## [13] "de" "med" "han" "av" "ikke" "ikkje"
## [19] "der" "så" "var" "meg" "seg" "men"
## [25] "ett" "har" "om" "vi" "min" "mitt"
## [31] "ha" "hadde" "hun" "nå" "over" "da"
## [37] "ved" "fra" "du" "ut" "sin" "dem"
## [43] "oss" "opp" "man" "kan" "hans" "hvor"
## [49] "eller" "hva" "skal" "selv" "sjøl" "her"
## [55] "alle" "vil" "bli" "ble" "blei" "blitt"
## [61] "kunne" "inn" "når" "være" "kom" "noen"
## [67] "noe" "ville" "dere" "som" "deres" "kun"
## [73] "ja" "etter" "ned" "skulle" "denne" "for"
## [79] "deg" "si" "sine" "sitt" "mot" "å"
## [85] "meget" "hvorfor" "dette" "disse" "uten" "hvordan"
## [91] "ingen" "din" "ditt" "blir" "samme" "hvilken"
## [97] "hvilke" "sånn" "inni" "mellom" "vår" "hver"
## [103] "hvem" "vors" "hvis" "både" "bare" "enn"
## [109] "fordi" "før" "mange" "også" "slik" "vært"
## [115] "være" "båe" "begge" "siden" "dykk" "dykkar"
## [121] "dei" "deira" "deires" "deim" "di" "då"
## [127] "eg" "ein" "eit" "eitt" "elles" "honom"
## [133] "hjå" "ho" "hoe" "henne" "hennar" "hennes"
## [139] "hoss" "hossen" "ikkje" "ingi" "inkje" "korleis"
## [145] "korso" "kva" "kvar" "kvarhelst" "kven" "kvi"
## [151] "kvifor" "me" "medan" "mi" "mine" "mykje"
## [157] "no" "nokon" "noka" "nokor" "noko" "nokre"
## [163] "si" "sia" "sidan" "so" "somt" "somme"
## [169] "um" "upp" "vere" "vore" "verte" "vort"
## [175] "varte" "vart"
De fleste vil umiddelbart se at det er noen problemer med denne stoppordboken: den har både nynorsk- og bokmålord, den har mange ord som brukes ekstremt sjelden, og mangler noen viktige funksjonsord (som “hvilket”). Skulle vi likevel sammenligne de mest brukte ordene i No.4-tekstene, ser vi at det er mer mening i dataene når vi har fjernet
library(tidytext)
load("./data/no4.rda")
no4_tokens <- no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst) %>%
count(token)
# Med stoppord
no4_tokens %>%
slice_max(order_by = n,
n = 2,
with_ties = FALSE)
## # A tibble: 24 × 4
## # Groups: spor, titler [12]
## spor titler token n
## <int> <chr> <chr> <int>
## 1 1 Parentes at 5
## 2 1 Parentes var 5
## 3 2 En av de levende jeg 32
## 4 2 En av de levende være 17
## 5 3 Hvilket vi hvilket 11
## 6 3 Hvilket vi du 10
## 7 4 Hold deg fast du 15
## 8 4 Hold deg fast deg 14
## 9 5 Feil sted du 27
## 10 5 Feil sted er 19
## # … with 14 more rows
# Uten stoppord
no4_tokens %>%
filter(token %in% quanteda::stopwords("no") == FALSE) %>%
slice_max(order_by = n,
n = 2,
with_ties = FALSE)
## # A tibble: 24 × 4
## # Groups: spor, titler [12]
## spor titler token n
## <int> <chr> <chr> <int>
## 1 1 Parentes fortell 2
## 2 1 Parentes funnet 2
## 3 2 En av de levende levende 11
## 4 2 En av de levende alltid 8
## 5 3 Hvilket vi hvilket 11
## 6 3 Hvilket vi tid 7
## 7 4 Hold deg fast fast 13
## 8 4 Hold deg fast hold 13
## 9 5 Feil sted vei 10
## 10 5 Feil sted feil 3
## # … with 14 more rows
En alternativ måte å beregne stoppord på, er å bruke TF-IDF, eller rettere sagt IDF-delen av TF-IDF til å regne ut hvile ord som er minst unike over alle tekstene i korpuset.
idf_stop <- no4_tokens %>%
bind_tf_idf(token, titler, n) %>%
ungroup() %>%
select(token, idf) %>%
unique() %>%
arrange(idf)
idf_stop
## # A tibble: 492 × 2
## token idf
## <chr> <dbl>
## 1 det 0
## 2 jeg 0
## 3 er 0.0870
## 4 å 0.182
## 5 ikke 0.182
## 6 på 0.182
## 7 alt 0.182
## 8 du 0.288
## 9 meg 0.288
## 10 så 0.288
## # … with 482 more rows
Fordelen med å gjøre det på denne måten, er at stoppordlisten tilpasser seg korpuset man jobber med. Om man, for eksempel, har hange stortingstaler, vil ord som president, representant, storting, osv være ganske meningsløse fordi de brukes så ofte, og vil ha lav IDF.
Det er likevel også noen utfordringer med denne metoden å identifisere stoppord. Det viktiste er hvor man skal sette grensen for hva som er et stoppord og ikke. Her er det ingen fasit, men krever god inspeksjon av data og litt eksperimentering. I akkurat No.4-albumet er det spesielt vanskelig å sette en grense fordi det ikke er et stort korpus; ord som åpenbart er stoppord får ikke mulighet til å bli brukt nok til å få lav IDF.
La oss likevel se på toppord etter å ha fjernet de ordene som har laver IDF enn 1.
idf_stop <- idf_stop %>%
filter(idf < 1)
no4_tokens %>%
filter(token %in% idf_stop$token == FALSE) %>%
slice_max(order_by = n,
n = 2,
with_ties = FALSE)
## # A tibble: 24 × 4
## # Groups: spor, titler [12]
## spor titler token n
## <int> <chr> <chr> <int>
## 1 1 Parentes fortell 2
## 2 1 Parentes funnet 2
## 3 2 En av de levende være 17
## 4 2 En av de levende skal 13
## 5 3 Hvilket vi hvilket 11
## 6 3 Hvilket vi hvilken 7
## 7 4 Hold deg fast fast 13
## 8 4 Hold deg fast hold 13
## 9 5 Feil sted vei 10
## 10 5 Feil sted feil 3
## # … with 14 more rows
Resultatet blir ikke så veldig forskjellig fra å bruke stoppordlisten, som kanskje er et bra tegn.
5.3.2 Punktsetting og tall
Andre ting som er vanlige å fjerne fra et korpus før man transformerer til tall, er punktsetting og tall. Punktsetting er vanlig å fjerne, fordi det ikke gir oss noe særlig informasjon i en standard sekk med ord-modell. Likevel kan punktsetting være relevant informasjon om man vil dele opp tekster i for eksempel setninger. Det kan også være relevant å ta vare på ting som paragraftegnet (§) om man jobber med lovtekster. Tenk nøye gjennom hvilke trekk du fjerner, før du fjerner dem.
I unnest_tokens()-funksjonen fra tidytext fjernes punktsetting automatisk (men ikke alt):
no4_tokens <- no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst)
table(str_detect(no4_tokens$token, "[[:punct:]]"))
##
## FALSE TRUE
## 2395 2
no4_tokens$token %>%
.[which(str_detect(., "[[:punct:]]"))]
## [1] "you're" "you're"
Hvis du vil ta vare på punksetting kan du spesifisere dette i unnest_tokens():
no4_tokens <- no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst,
strip_punct = FALSE)
table(str_detect(no4_tokens$token, "[[:punct:]]"))
##
## FALSE TRUE
## 2395 250
Videre kan vi spesifisere at tall skal fjernes (default er at de ikke fjernes):
no4_tokens <- no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst,
strip_numeric = TRUE)
table(str_detect(no4_tokens$token, "[0-9]"))
##
## FALSE
## 2394
5.4 Rotform av ord
En videre antagelse man ofte gjør i kvanitativ analyse av tekst, er at samme ord med forskjellig bøyning betyr det samme. For eksempel at “hus” og “huset” egentlig er samme ord. Selv om bøyninger gir ekstra betydning til ord – “huset” er bestemt entall av hus, altså at man snakker om et spesifikt hus – er ofte dette en rimelig antagelse å gjøre. Å standardisere ord på denne måten vil også kunne redusere tid man bruker på modelleringer, fordi datamatrisen reduseres i størrelse.
Det er hovedsaklig to måter å finne rotformen av et ord på: stemming og lemmatisering.
5.4.1 Stemming
Stemming finner rotformen av et ord ved å kutte det ned til sitt minste komponent som gir mening uten at det blir et annet ord (i de fleste tilfeller).
stem1 <- tokenizers::tokenize_words("det satt to katter på et bord") %>%
unlist() %>%
quanteda::char_wordstem(., language = "no")
stem2 <- tokenizers::tokenize_words("det satt en katt på et bordet") %>%
unlist() %>%
quanteda::char_wordstem(., language = "no")
cbind(stem1, stem2, samme = stem1 == stem2)
## stem1 stem2 samme
## [1,] "det" "det" "TRUE"
## [2,] "satt" "satt" "TRUE"
## [3,] "to" "en" "FALSE"
## [4,] "katt" "katt" "TRUE"
## [5,] "på" "på" "TRUE"
## [6,] "et" "et" "TRUE"
## [7,] "bord" "bord" "TRUE"
Som vi ser, fungerer dette ganske godt! Problemene med stemming oppstår når vi bøying av ord er uregelmessig (for eksempel svake verb):
stem1 <- tokenizers::tokenize_words("jeg har én god fot og én dårlig hånd") %>%
unlist() %>%
quanteda::char_wordstem(., language = "no")
stem2 <- tokenizers::tokenize_words("jeg har to gode føtter og to dårlige hender") %>%
unlist() %>%
quanteda::char_wordstem(., language = "no")
cbind(stem1, stem2, samme = stem1 == stem2)
## stem1 stem2 samme
## [1,] "jeg" "jeg" "TRUE"
## [2,] "har" "har" "TRUE"
## [3,] "én" "to" "FALSE"
## [4,] "god" "god" "TRUE"
## [5,] "fot" "føtt" "FALSE"
## [6,] "og" "og" "TRUE"
## [7,] "én" "to" "FALSE"
## [8,] "dår" "dår" "TRUE"
## [9,] "hånd" "hend" "FALSE"
Her fungerer stemmingen godt på de regelmessige adjektivene (“god/gode” og “dårlig/dårlige”), mens den ikke fungerer på de uregelmessige substantivene (“fot/føtter” og “hånd/hender”). Noen vil kanksje påpeke at det “hånd/hender” og “fot/føtter” virkelig ikke er det samme, og det er en vurdering man må gjøre. Det vil uansett (nesten) alltid være tilfelle at samme tekst med og uten stemming (og lemmatisering – se under) er mer lik seg selv enn en helt annen tekst.
5.4.2 Lemmatisering
Lemmatisering skiller seg fra stemming ved at man bruker konteksten bruker en trent modell som tolker den grammatiske formen til et ord og finner rotformen til dette ordet med en ordbok. Dette gjør at man letter på problemet der ord er like, men betyr forskjellige ting i forskjellige kontekster. For eksempel vil ordet “merke” kunne bety både et fysisk merke som substantiv (arr for eksempel) og det å merke noe (“merke at noe skjer”) som verb. Lemmatisering skjer gjerne ved at man bruker en tagger som analyserer teksten man gir og spytter ut litt forskjellige egenskaper ved hvert ord i teksten.
For norsk er det litt begrensede ressurser på lett tilgjengelige lemmatiserere. Den enkleste å bruke kommer fra pakken spacyr (samme forfattere som quanteda). Her må man både ha en fungerende versjon av Python og spaCy før man installerer spacyr i R. I tillegg må man installere språkpakker for de språkene man skal bruke. For norsk, bruker vi her nb_core_news_lg.
library(spacyr)
spacy_initialize("nb_core_news_lg")
spacy_eksempel <- spacy_parse(c("jeg har én god fot og én dårlig hånd",
"jeg har to gode føtter og to dårlige hender"))
spacy_eksempel
## doc_id sentence_id token_id token lemma pos entity
## 1 text1 1 1 jeg jeg PRON
## 2 text1 1 2 har ha VERB
## 3 text1 1 3 én én NUM
## 4 text1 1 4 god god ADJ
## 5 text1 1 5 fot fot NOUN
## 6 text1 1 6 og og CCONJ
## 7 text1 1 7 én én NUM
## 8 text1 1 8 dårlig dårlig ADJ
## 9 text1 1 9 hånd hånd NOUN
## 10 text2 1 1 jeg jeg PRON
## 11 text2 1 2 har ha VERB
## 12 text2 1 3 to to NUM
## 13 text2 1 4 gode god ADJ
## 14 text2 1 5 føtter fot NOUN
## 15 text2 1 6 og og CCONJ
## 16 text2 1 7 to to NUM
## 17 text2 1 8 dårlige dårlig ADJ
## 18 text2 1 9 hender hånd NOUN
Her ser vi at lemma på “hånd”/“hender” har blitt “hånd” og “fot”/“føtter” har blitt “fot”. Akkurat som vi vil. Likevel er ikke lemmatisereren til spacyr helt perfekt og man får en advarsel om dette når man kjører taggeren. Variablene vi får av taggeren er:
| Variabel | Beskrivelse |
|---|---|
| doc_id | Id for teksten |
| sentence_id | Indikator for setningsnummer i teksten |
| token_id | Indeks for ord i setningen |
| token | Den originale versjonen av ordet i teksten |
| lemma | Lemmatisert (rotform) ord |
| pos | Part-of-speech (taledeler) |
| entity | Navngitt enhet (named entity) som Oslo, Solveig, Slottet, etc |
Siden spaCy ikke er alltid fungerer på lemmatisering, vil vi også nevne at Universitetet i Oslo og Universitetet i Bergen har sammarbeidet om å lage en tagger, som virker veldig godt. Og vi anbefaler denne om man skal bruke tagger i en evt. masteroppgave eller lignende. Taggeren heter Oslo-Bergen-tagger (OBT). Den er ikke veldig enkel å sette opp (det enkleste er å sette det opp som via en docker container), men for å eksemplifisere hvordan den virker, har jeg kjørt stem2-teksten over gjennom taggeren og leser resultatet inn i R ved hjelp av read_obt()-funksjonen i pakken stortingscrape:
tekst2 <- stortingscrape::read_obt("./data/lemmatisering/tekst2_tag.txt")
tekst2
## # A tibble: 10 × 7
## # Groups: sentence [1]
## sentence index token lwr lemma pos morph
## <dbl> <int> <chr> <chr> <chr> <chr> <chr>
## 1 1 1 jeg jeg jeg pron "ent pers hum nom 1"
## 2 1 2 har har ha verb "pres <aux1/perf_part>"
## 3 1 3 to to to det "fl kvant"
## 4 1 4 gode gode god adj "fl pos"
## 5 1 5 føtter føtter fot subst "appell mask ub fl"
## 6 1 6 og og og konj ""
## 7 1 7 to to to det "fl kvant"
## 8 1 8 dårlige dårlige dårlig adj "fl pos"
## 9 1 9 hender hender hånd subst "appell fem ub fl"
## 10 1 10 . . $. clb "<<< <punkt> <<<"
Her har vi fått et datasett hvor hver rad er et ord (inkl. punktsetting) og kolonnene er forskjellige egenskaper ved dette ordet. Disse variablene viser følgende:
| Variabel | Beskrivelse |
|---|---|
| sentence | Indikator for setningsnummer i teksten |
| index | Indeks for ord i setningen |
| token | Den originale versjonen av ordet i teksten |
| lwr | Den originale versjonen av ordet i teksten med små bokstaver |
| lemma | Lemmatisert (rotform) ord |
| pos | Part-of-speech (taledeler) |
| morph | Morfologi (oppbyggingen av ordet via dets minste deler) |
Vi diskuterer taledeler litt mer under, og morfologi vil vi ikke bruke noe særlig tid på her, selv om det kan være veldig interessant. Det vi skal legge merke til er at kolonnen lemma viser at ordene “hender” og “føtter” har blitt bøyd riktig til “hånd” og “fot”.
5.5 Taledeler (parts of speech)
I både spaCy og OBT spytter taggeren ut noe som kalles parts of speech (PoS) eller taledeler. Dette er, kort sagt, den grammatiske formen til et ord. Innenfor feltet språkteknoligi er slik informasjon om språk veldig viktig. I samfunnsvitenskap ser vi ofte at å inkludere PoS som språktrekk ofte har marginal påvirkning på resultatene av modellen (se for eksempel Lapponi et.al (2019).
Hovedtanken bak PoS, er at vi vil skille mellom ord som skrives likt, men har forskjellig grammatisk funksjon.
grei1 <- "den snegler seg fremover"
grei2 <- "det er mange snegler her"
grei <- spacy_parse(c(grei1, grei2)) %>%
tibble() %>%
select(doc_id, token, pos) %>%
filter(str_detect(token, "snegl")) %>%
mutate(token_pos = str_c(token, ":", pos))
grei
## # A tibble: 2 × 4
## doc_id token pos token_pos
## <chr> <chr> <chr> <chr>
## 1 text1 snegler VERB snegler:VERB
## 2 text2 snegler NOUN snegler:NOUN
I dette tilfellet ville vi fått samme ord (snegler) om vi vektoriserte på kolonnen token, mens vi ville fått forskjellige ord om vi vektoriserte på kolonnen token_pos.
5.6 ngrams
Når vi lager en “sekk med ord”, splitter vi ofte teksten inn i ett og ett ord. Ordene kaller vi gjerne tokens (derav funksjonen unnest_tokens()). Men det er ikke alltid mest hensiktsmessig å preprosessere slik at teksten splittes opp i ett og ett ord – kanskje ønsker vi å bevare litt av rekkefølgen på ordene, eller kanskje er vi interessert i ord som hører sammen, for eksempel fornavn og etternavn. Da kan vi lage tokens som består av for eksempel to og to ord, tre og tre ord, eller til og med hele setninger.
Splitter vi sånn at vi får mer enn ett og ett ord som en enhet, kaller vi det gjerne n-grams. Ønsker vi å referere til et spesifikt antall ord i en token, kan vi bruke denne terminologien:
- Ett og ett ord: Unigram
- To og to ord: Bigrams
- Tre og tre ord: Trigrams
For å splitte tekst inn i unigram setter vi token = "words" i unnest_tokens-funksjonen. Dette er også default for funksjonen, så dersom vi ikke spesifiserer noen ting, så er det unigrams vi får.
no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst,
token = "words")
## # A tibble: 2,397 × 3
## # Groups: spor, titler [12]
## spor titler token
## <int> <chr> <chr>
## 1 1 Parentes forstyrrer
## 2 1 Parentes jeg
## 3 1 Parentes eller
## 4 1 Parentes har
## 5 1 Parentes du
## 6 1 Parentes tid
## 7 1 Parentes til
## 8 1 Parentes å
## 9 1 Parentes høre
## 10 1 Parentes på
## # … with 2,387 more rows
For å hente ut bigrams, sett token = "ngrams" og n = 2. Kan du tenke deg hva vi ville fått dersom vi hadde satt n = 3?
no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst,
token = "ngrams",
n = 2)
## # A tibble: 2,385 × 3
## # Groups: spor, titler [12]
## spor titler token
## <int> <chr> <chr>
## 1 1 Parentes forstyrrer jeg
## 2 1 Parentes jeg eller
## 3 1 Parentes eller har
## 4 1 Parentes har du
## 5 1 Parentes du tid
## 6 1 Parentes tid til
## 7 1 Parentes til å
## 8 1 Parentes å høre
## 9 1 Parentes høre på
## 10 1 Parentes på meg
## # … with 2,375 more rows
5.7 Word embeddings
Når vi skal jobbe med tekst, må vi finne en måte å gjøre om teksten til tall. Datamaskinen jobber best med tall. Prosessen med å gjøre om ord til tall kalles “vektorisering”. Det finnes flere måter å vektorisere på, deriblant:
- Sekk av ord (bag of words): Gir oss frekvensen av ord per dokument.
- TF-IDF: Gir oss frekvens av ord per dokument, vektet etter hvor hyppig ordet forekommer i dokumentmassen.
- Word embeddings: Gir oss en vektor i et lav-dimensjonalt rom for hvert ord.
Mange som jobber med NLP (natural language processing) henfaller til word embeddings fordi det har en del fordeler i forhold til å bruke frekvens:
- Det gir et estimat på likhet
- Det muliggjør automatisk generalisering
- Det kan (til dels) måle et ords mening
I tillegg får vi data som er mer tettpakket – kolonnene har ikke så mange nuller, noe som gir færre dimensjoner, noe som reduserer sjansen for overtilpasning.
Det finnes flere pakker for word embeddings i R, for eksempel word2vec, GloVe og fastText.
Her følger et eksempel med hvordan man kan bruke fastText for å lage word embeddings:
Steg 1: Som vanlig må vi huske å preprosessere teksten før vi setter i gang med analysene våre.
stoppord <- stopwords::stopwords("Norwegian") # Finner stoppord fra den norske bokmålslista til "stopwords" pakken
stoppord_boundary <- str_c("\\b", stoppord, "\\b", # Lager en vektor med "word boundary" for å ta ut ord fra en streng
collapse = "|") # Setter | mellom hver ord for å skille dem fra hverandre med "eller"-operator
no4_prepped <- no4 %>%
mutate(tekst = str_to_lower(tekst), # Setter all tekst til liten bokstav
tekst = str_replace_all(tekst, "[0-9]+", ""), # Fjerner tall fra teksten
tekst = str_squish(tekst), # Fjerner whitespace
tekst = str_replace_all(tekst, "\\b\\w{1,1}\\b", ""), # Fjerner enkeltbokstaver
tekst = str_replace_all(tekst, stoppord_boundary, ""), # Fjerner stoppord
tekst = str_replace_all(tekst, "[:punct:]", "")) # Fjerner all punktsetting
Steg 2: Fasttext er en algoritme utviklet av Facebook. De har laget den slik at den skal fungere for alle utviklere der ute, enten de jobber i terminalen, i Python, i Java, i R, eller i noe annet. Derfor krever de en input som er litt utenom det vanlige – et vanlig tekstdokument, altså en .txt fil. Dette kan vi lage i R med koden under.
no4_tekster <- tempfile() # Oppretter en midlertidig fil på PCen
writeLines(text = no4_prepped %>% pull(tekst), con = no4_tekster) # I denne filen skriver vi inn teksten fra datasettet.
Steg 3: Nå kan vi kjøre modellen for å lage word embeddings. Noen av valgene vi må ta er:
- Hvor stort skal kontekstvinduet være? Altså hvor mange ord foran og bak hovedordet skal algoritmen bruke for å forstå konteksten.
- Hvor mange dimensjoner skal det være? Her får vi automatisk 100 dimensjoner. For å endre dette måtte vi kjørt modellen via terminalen.
- Hvilken modell skal vi bruke? Fasttext tilbyr både
cbowogskipgram.
library(fastTextR)
ft_cbow <- ft_train(no4_tekster,
type = "cbow", # Velger cbow modell
control = ft_control(window_size = 5L)) # Setter kontekstvinduet til 5
ft_skipgram <- ft_train(no4_tekster,
type = "skipgram", # Velger skipgram modell
control = ft_control(window_size = 5L))
Vi kan finne ord-vektorene med ft_word_vectors. Legg merke til at de går til 100. Vi har altså 100 dimensjoner. Hadde vi brukt “sekk av ord”, hadde vi hatt like mange dimensjoner som vi har ord, altså nesten 1000. Vi har, med andre ord, redusert antall dimensjoner ganske kraftig.
ft_word_vectors(ft_cbow, c("fordi", "himmel"))
## [,1] [,2] [,3] [,4] [,5]
## fordi 0.0004229803 -3.126769e-05 0.0001531525 -0.0000911542 0.0005640839
## himmel 0.0003400762 -2.810812e-04 0.0003625524 -0.0001698947 -0.0002005034
## [,6] [,7] [,8] [,9] [,10]
## fordi 0.0001611611 9.468633e-06 -0.0005698582 2.882037e-04 1.239537e-04
## himmel 0.0002343899 -4.229283e-04 -0.0002758013 -2.557085e-05 5.703386e-05
## [,11] [,12] [,13] [,14] [,15]
## fordi -4.968944e-05 -0.0005556891 2.990265e-04 -4.937951e-04 0.0003009799
## himmel 2.508874e-04 -0.0001195825 7.999406e-07 -9.304351e-05 -0.0004802363
## [,16] [,17] [,18] [,19] [,20]
## fordi 0.0004996158 -0.0006972131 0.0003386495 2.059648e-04 0.0001499537
## himmel 0.0003660462 0.0002463926 -0.0001130784 1.981639e-05 -0.0005010382
## [,21] [,22] [,23] [,24] [,25]
## fordi -0.0006710046 -0.0002009657 -0.0005896706 -0.000539655 -6.203828e-04
## himmel 0.0002415862 0.0003886326 0.0002367772 -0.000117599 2.979174e-05
## [,26] [,27] [,28] [,29] [,30]
## fordi 0.0003445543 1.054367e-04 -7.411114e-05 0.0006279270 -0.0005203970
## himmel 0.0003994071 -1.302021e-05 4.160340e-05 0.0005499916 -0.0002487123
## [,31] [,32] [,33] [,34] [,35]
## fordi -1.410886e-04 0.0002264874 0.0006179944 -0.0002261649 -2.146827e-04
## himmel -7.161538e-05 -0.0002718160 -0.0003046337 0.0005525587 -8.663347e-05
## [,36] [,37] [,38] [,39] [,40]
## fordi 7.682834e-05 0.0006319320 0.0005043782 -0.0004292535 0.0007084003
## himmel 4.861114e-04 -0.0005046887 -0.0001433692 0.0004297458 0.0003608722
## [,41] [,42] [,43] [,44] [,45]
## fordi -0.0007128998 0.0001443866 0.0004862696 1.229172e-04 -0.0001680819
## himmel 0.0003313405 0.0005343112 0.0002021801 9.993793e-05 -0.0001391745
## [,46] [,47] [,48] [,49] [,50]
## fordi 4.094304e-04 0.0003071116 -0.0004071383 0.0006898485 0.0005943870
## himmel -6.519686e-05 -0.0002976863 -0.0001010489 0.0001225848 -0.0002687823
## [,51] [,52] [,53] [,54] [,55]
## fordi 0.0000633754 -0.0002592132 -3.742145e-04 -0.0003261300 -0.0005033756
## himmel -0.0004098963 -0.0001246714 -2.255533e-05 -0.0002819263 -0.0004302305
## [,56] [,57] [,58] [,59] [,60]
## fordi 0.0005689726 -0.0007093063 -0.0006017384 -0.0004089687 5.104363e-05
## himmel -0.0001421283 0.0005541204 0.0005207795 0.0003844925 -3.888329e-04
## [,61] [,62] [,63] [,64] [,65]
## fordi 0.0006562477 0.0004448018 5.268937e-05 0.0002097173 0.0006626237
## himmel -0.0002631767 -0.0003501290 -5.216807e-04 -0.0001643755 0.0001456836
## [,66] [,67] [,68] [,69] [,70]
## fordi 0.0004130471 0.0005810333 0.0001703538 -0.0004132454 -0.0005853543
## himmel -0.0002122321 -0.0002969235 -0.0004152645 0.0005410713 -0.0004272975
## [,71] [,72] [,73] [,74] [,75]
## fordi 0.0004889697 2.109571e-04 -0.0001353624 0.0001184932 -0.0003778868
## himmel 0.0002760292 6.502134e-05 -0.0001330448 -0.0004659508 -0.0002136038
## [,76] [,77] [,78] [,79] [,80]
## fordi -0.0001506362 0.0006121492 -0.0006414849 0.0002733775 -0.0004687168
## himmel -0.0003862265 0.0001411103 0.0001751131 0.0003818365 0.0000309874
## [,81] [,82] [,83] [,84] [,85]
## fordi -0.0002362550 -0.0004116326 2.613955e-05 -6.308833e-04 -0.0006163829
## himmel -0.0001708977 0.0005427501 1.457292e-04 8.805923e-05 -0.0003328912
## [,86] [,87] [,88] [,89] [,90]
## fordi 6.234720e-04 0.0003603076 -0.0002887875 -0.0004656472 6.827115e-05
## himmel 2.321036e-05 0.0003714993 0.0002933164 0.0004211639 1.341513e-04
## [,91] [,92] [,93] [,94] [,95]
## fordi 3.228633e-05 0.0004867699 0.0002592817 0.0002948412 -0.0005433591
## himmel 3.277051e-04 0.0003368929 -0.0002967137 0.0003651592 0.0003919543
## [,96] [,97] [,98] [,99] [,100]
## fordi -0.0001487845 -0.0002300390 -0.0005808019 -0.0002429863 0.0003132335
## himmel 0.0005056034 0.0005507146 0.0002853644 0.0001191367 0.0002650023
For å finne ut hvilke ord som likner mest, kontekstmessig, på et annet ord, kan vi bruke funksjonen ft_nearest_neighbors.
ft_nearest_neighbors(ft_cbow, "himmel", k = 5L)
## alltid egentlig fast våre alt
## 0.2052628 0.1630321 0.1239293 0.1176816 0.0933961
Som du ser, virker det ikke som modellen i særlig god grad klarer å fange opp hvilke ord som likner på “himmel”. Ved mindre vi har ekstremt store mengder med data å trene våre word embeddings på, er det ofte best å bruke ferdigtrent data. Du kan finne facebook sine ferdigtrente word embeddings i diverse språk her: https://fasttext.cc/docs/en/crawl-vectors.html
6 Veildedet læring
Valgkamper dreier seg gjerne om enkelte saker som er viktige der og da, men viktigheten av disse sakene skifter over tid. Innvandring, miljø, bompenger, enkelte saker får mer oppmerksomhet enn andre under valg. Derfor kan det være interessant å spørre seg hvor vidt det at noe er en sak i valgkampen, egentlig har noe å si for andre prosesser, for eksempel agenda, diskusjoner, og iverksetting av faktisk politikk. Med utgangspunkt i saker knyttet til rase var det var dette Gillion (2016) satte seg fore å gjøre (Grimmer et al. 2022). Han samlet inn store mengder tekst fra presidentdebatter, taler, radioinnlegg, også videre, for å kunne måle hvordan retorikk i valgkampen påvirket andre deler av politikken.
Første steg i prosessen etter å ha samlet inn data, handler om å måle. Hvordan visste Gillion at en tekst (debattinnlegg, tale, eller liknende) handlet om rase? Han kunne kanskje brukt noen stikkord og sagt at dersom disse og disse ordene dukker opp, så handler teksten om rase, men dette ville vært en grovsortering. Isteden ansatte Gillion noen folk til å lese gjennom tekstene, vurdere om de handlet om rase eller ikke, og merke dem deretter.
Gillion sin analyseenhet er avsnitt, og han har millioner av avsnitt som må merkes. Dermed har han et problem. Hvis mennesker gjør denne jobben vil det ta evigheter, og det blir dyrt å lønne dem for strevet. Derfor ba Gillion disse menneskene om bare å merke (også kalt å “kode”)4 noen av tekstene. Deretter brukte han denne kunnskapen om hvor vidt en viss type tekst ga uttrykk for å handle om rase, til å trene opp en modell som kunne merke de resterende tekstene. Dette er et godt eksempel på hva veiledet maskinlæring brukes til.
Veiledet læring fungerer godt når vi har merket data, det vil si at vi vet hva Y er. I tilfellet over, var Y kjent for enkelte av tekstene. Takket være de som merket avsnittene, vet vi noe om hvordan en viss kombinasjon av ord (altså Xene) øker sannsynligheten for at et avsnitt omhandler rase (Y). Hva som er Xene, vil variere avhengig av hvordan vi har vektorisert teksten (fra tekst til tall), men hvis vi bruker en “sekk med ord”-framgangsmåte, så er X et ord, og \(\beta\) er effekten av dette ordet på om avsnittet handler om rase. Enkelt demonstrert får vi:
\(rase_D = \alpha + \beta_1ord1 + \beta_2ord2 + \beta_3ord3 ...\)5
6.0.1 Merket (“labelled”) data
Å jobbe med maskinlæring er omfattende, og her gir vi kun en liten introduksjon. Det kan likevel være fint å se hvordan dette gjøres i R, for å få en oversikt over hvordan det fungerer i praksis. Her viser jeg hvordan man kan klassifisere tekster. Estimering kalles klassifisering når målet er å estimere en kategorisk variabel. Hadde Y vært kontinuerlig, hadde vi kalt det regresjon.
Som eksempel bruker vi innlegg fra et datasett som har lagret diverse TED-talks. Dette datasettet har 5631 rader (altså talks). Siden TED er et forum for å snakke om nye ideer, kan vi tenke oss at dette er et datasett som reflekterer noen av de gryende problemstillingene i samfunnet. La oss si at vi er interessert i å vite hvor mange av disse talene som handler om politikk. En mulighet hadde vært å lese enkelte av talene, merke dem etter om de handlet om politikk eller ikke, og brukt denne informasjonen for å klassifisere resten av talene. I dette tilfellet har datasettet allerede en kolonne kalt “topics” som lister opp hva TED-talken har handlet om. I plottet under kan du selv utforske hvilke temaer de ulike TED-talksene handlet om, og hvilke temaer som gikk igjen mest.
library(tidyverse)
library(jsonlite)
library(plotly)
tedtalks <- read_csv("./data/teds.csv") %>% # Leser inn data fra en .csv-fil
janitor::clean_names() # Bruker funksjonen "clean_names" fra pakken "janitor" for å gjøre variabelnavnene litt penere
tedtalks_subset <- tedtalks %>% # Fra objektet tedtalks
rename(doc_id = id) %>% # Endrer navn på variabelen "id" til å hete "doc_id" isteden
select(doc_id, transcript, topics) %>% # Henter ut variablene doc_id, transcript og topics
mutate(topics = map(topics, # Variabelen topics ligger inne i json-format, jeg bruker "map" for å gå over hver rad
~ fromJSON(.) %>% # og gjør om fra json-format slik at vi får to variabler "id" på topic og "name" på topic
as_tibble())) %>% # Gjør om til en vanlig tibble (en R dataframe)
unnest(cols = c(topics)) %>% # Hver talk har flere topics, når jeg bruker unnest får hver topic sin egen rad
select(-id) # Fjerner variabelen som indikerer topicet sin id
tedtalks_subset %>%
count(name) %>% # Teller opp hvor mange ganger hver topic dukker opp
plot_ly(x = ~n, # Plotter med plotly-pakken (kunne også brukt ggplot, men da blir det ikke interaktivt)
y = ~name, # Setter antall ganger topic dukker opp på x-aksen og navnet på topicet på y-aksen
text = ~name, # Når man hovrer over søylene, skal man få opp navn på topic
type = "bar") %>% # Lager et stolpediagram (bar chart)
layout(xaxis = list(title = ""), # Ønsker ikke at det skal være tekst på y-eller x-aksen
yaxis = list(title = "", categoryorder = "total ascending")) # Sorter søylene i synkende rekkefølge ift. y-aksen (altså antallet)
Jeg bruker kolonnen “topics” for å lage en variabel som sier om en talk handlet om politikk. Framgangsmåten er slik: hvis talen har enten “politics” eller “international relations” som en topic, så handler den om politikk, hvis ikke handlet den om noe annet. I koden under strukturerer jeg datasettet slik at jeg får en variabel, “tema”, med et merke på tekstene som enten er “politikk” eller “annet”.
tedtalks_subset <- tedtalks_subset %>%
filter(name %in% c("politics", "international relations")) %>% # Hent ut de talene som har politics og/eller international relations som tema
mutate(tema = "politikk") %>% # Lag en variabel "topic" med verdien "politikk".
select(doc_id, tema) %>% # Hent ut id på TED-talken og dens topic
unique() # Fjern duplikater
ted <- tedtalks_subset %>%
full_join(tedtalks %>% # Sett sammen datasettet som bare inneholder talks som handlet om politikk, med det fulle datasettet...
select(id, transcript, topics), # ...der jeg plukker ut kun variablene "id", "transcript" og "topics"
by = c("doc_id" = "id")) %>% # Endre navn på id til doc_id
mutate(tema = replace_na(tema, "annet")) %>% # Variabel "tema" har missingverdien for de talkene som ikke handler om politikk, jeg bytter ut missing med "annet"
select(transcript, tema) %>% # Hent ut to kolonner, kolonnen med tekst (transcript) og kolonnen med merke (tema)
rename(text = transcript) %>% # Endre navn på transcript-variabelen til "text" (for at senere kode skal fungere)
drop_na(text) # Fjern missingverdier fra variabelen "text"
Dette er et ubalansert datasett. Det er 249 TED-talks som handler om politikk, og 4734 TED-talks som handler om noe annet (etter at jeg fjernet missingverdier).
ted %>%
count(tema) # Tell opp hvor mange rader som har hver sin verdi på variabelen "tema".
## # A tibble: 2 × 2
## tema n
## <chr> <int>
## 1 annet 4734
## 2 politikk 249
I dette tilfellet er hele datasettet merket. Typisk vil vi være interessert i å bruke veiledet læring hvis vi plutselig fikk tilgang på flere TED-talks som ikke hadde informasjon om hvor vidt de handlet om politikk. Kanskje ville dette vært tilfellet for nyere eller eldre innlegg? Kluet er da å bruke den dataen vi har, for å trene en modell som kan klassifisere TED-talks der vi ikke vet tema.
6.0.2 Treningsdata, valideringsdata og testdata
Et viktig poeng når man jobber med maskinlæring, er at modellen kan bli veldig god på å forstå mønstre og sammenhenger innad i data. Den kan faktisk bli så god at hvis du gir den en tekst, så vet den helt sikkert om denne teksten handler om politikk eller ikke. Men det at modellen er veldig god på den dataen vi bruker for å estimere modellen, betyr ikke at den kommer til å være god på data som ikke er brukt for å estimere modellen. Her er det variasjon og forskjelligheter som gjør at de sammenhengene modellen tenkte var brennsikre for sin egen data, ikke gjelder ellers. Dette problemet kaller overtilpasning (overfitting).
For å unngå at modellen overtilpasser seg, velger vi å splitte opp data og gi modellen porsjoner av datasettet å trene på.
- Treningsdata: Den dataen modellen bruker for å estimere klassifikasjonsmodellen.
- Valideringsdata: Den dataen modellen bruker regelmessig for å sjekke at klassifiseringene ikke er overtilpasset treningsdata.
- Testdata: Datasettet som brukes som en endelig test på om modellen klarer å klassifisere observasjonene godt (nok).
library(tidymodels)
set.seed(930) # Denne koden gjør at R gir tilsvarende resultater hver gang man kjører samme kode (dvs. splitter på samme sted)
ted_splitt <- initial_split(ted, # Del datasettet i to
prop = 0.8, # 80 prosent av data (dvs. 80 prosen av radene/talksene) skal gå inn i treningsdata, resten blir testdata
strata = tema) # Passer på at Y, tema, er godt representert i både treningsdatasett og testdatasett
ted_trening <- training(ted_splitt) # Lager treningsdatasett
ted_test <- testing(ted_splitt) # Lager testdatasett
ted_trening %>% head()
## # A tibble: 6 × 2
## text tema
## <chr> <chr>
## 1 "A public, Dewey long ago observed, is constituted through discussion a… poli…
## 2 "If you're here today -- and I'm very happy that you are -- you've all … poli…
## 3 "So, can we dare to be optimistic? Well, the thesis of \"The Bottom Bil… poli…
## 4 "I want to argue to you that in fact, politics and religion, which are … poli…
## 5 "Suppose that two American friends are traveling together in Italy. The… poli…
## 6 "So, indeed, I have spent my life looking into the lives of presidents … poli…
ted_test %>% head()
## # A tibble: 6 × 2
## text tema
## <chr> <chr>
## 1 "My name is Joseph, a Member of Parliament in Kenya. Picture a Maasai v… poli…
## 2 "I want to talk about the election. For the first time in the United St… poli…
## 3 "I want to talk about the transformed media landscape, and what it mean… poli…
## 4 "I'm going to talk about post-conflict recovery and how we might do pos… poli…
## 5 "Take a look at this picture. It poses a very fascinating puzzle for us… poli…
## 6 "I just want to say my name is Emmanuel Jal. And I come from a long way… poli…
ted_folds <- vfold_cv(ted_trening, # Splitt treningsdatasettet inn i valideringsdatasett
strata = tema, # Passer på at Y, tema, er godt representert i både valideringsdatasett og treningsdatasett
v = 5) # Splitter opp fem ganger (slik at vi får "5-fold cross validation")
ted_folds
## # 5-fold cross-validation using stratification
## # A tibble: 5 × 2
## splits id
## <list> <chr>
## 1 <split [3188/798]> Fold1
## 2 <split [3189/797]> Fold2
## 3 <split [3189/797]> Fold3
## 4 <split [3189/797]> Fold4
## 5 <split [3189/797]> Fold5
6.0.3 Preprosessering
Tekstene må preprosesseres før vi kan bruke en modell på dem. Her bruker jeg “textrecipes”, som er laget spesielt for å få en god arbeidsflyt når man jobber med maskinklæring på tekst. Under kan du se at vi får ut en dokument-term-matrise (document feature matrix, DFM), der radene er dokumenter (talks i vårt tilfelle), og variablene er ord. Siden vi har brukt TF-IDF, er verdiene en vektet representasjon av hvor viktig dette ordet er for det gitte dokumentet. Dette er akkurat slik vi har jobbet med før, men textrecipes pakken slenger på en pretekst foran ordet, derfor har alle ordene fortegnelsen “tfidf_text”.
library(textrecipes)
library(quanteda)
library(tm)
ted_oppskrift <- recipe(tema ~ ., data = ted_trening) %>% # Modellen jeg ønsker å kjøre - jeg vil estimere Y ved å bruke resten av dataene
step_mutate(text = str_to_lower(text)) %>% # Setter alle til liten bokstav
step_mutate(text = removeNumbers(text)) %>% # Fjerner tall
step_mutate(text = removePunctuation(text)) %>% # Fjerner punktsetting
step_tokenize(text) %>% # Tokeniserer teksten
step_stem(text) %>% # Lager ordstammer
step_stopwords(text, custom_stopword_source = stopwords("en")) %>% # Fjerner stoppord
step_tokenfilter(text, max_tokens = 1000, min_times = 2) %>% # Beholder tokens som dukker opp maks 1000 ganger, fjerner de som dukker opp mindre enn 2 ganger
step_tfidf(text) # Vektoriserer teksten med TF-IDF
prep(ted_oppskrift) %>% # Iverksetter preprosesseringsstegene slik beskrevet i oppskriften over
bake(new_data = NULL) %>% # Ser på hvordan oppskrifts-objektet ser ut
head(5) %>% select(1:5) # Henter ut de fem første radene, og de fem første kolonnene
## # A tibble: 5 × 5
## tema tfidf_text_ tfidf_text_abil tfidf_text_abl tfidf_text_abov
## <fct> <dbl> <dbl> <dbl> <dbl>
## 1 politikk 0 0.00197 0.00517 0
## 2 politikk 0.00417 0 0 0
## 3 politikk 0.0101 0 0 0.00537
## 4 politikk 0 0 0 0
## 5 politikk 0.00370 0 0.000877 0
6.0.4 Velg modell
Det finnes mange forskjellige modeller vi kan bruke for å estimere Y. Dere har sikkert vært borti lineær regresjon - dette er én type modell. Det finnes imidlertid flere, og noen av dem er svært avanserte. Så avanserte at noen kaller dem en “black box” - du putter data inn, modellen gjør noen kompliserte matematiske beregninger, og du får klassifiseringer ut. Dette gjelder spesielt modeller som bygger på nevrale nett og dyplæring. Vi skal ikke gå mye inn i ulike modeller i dette emnet, men pensum tar for seg applikasjonen av tre ulike modeller i statsvitenskap; support vector machine (D’Orazio et al., 2016, og random forest og logistisk regresjon (Muchlinski et al., 2016).
Hvordan implementerer vi disse modellene i R? Under demonstrerer jeg for hver av modellene ved bruk av tidymodels-pakken. Men først, la meg legge inn et objekt som forteller R litt om hvordan den skal beregne disse modellene.
contrl_preds <- control_resamples(save_pred = TRUE) # Velger å lagre prediksjonene etter at modellen har kjørt
6.0.4.1 Logistisk regresjon
glmn_spec <-
logistic_reg(penalty = 0.001, # Setter et par argumenter for å forhinde modeller fra å overtilpasse seg
mixture = 0.5) %>% # Dette er typisk noe man går fram og tilbake med (kalt å "tune" modellen)
set_engine("glmnet") %>% # Logistisk modell får vi ved å spesifisere "glmnet"
set_mode("classification") # Vi ønsker klassifisering, ikke regresjon
glm_wf <- workflow(ted_oppskrift, # Datasettet vårt etter preprosessering
glmn_spec) # Modellen som spesifisert over, altså logitisk
glm_rs <- fit_resamples( # Passer modellen ved å bruke testdata og valideringsdata i sekvens fem ganger
glm_wf, # Dette objektet forteller hva som er data og hva som er modellen
resamples = ted_folds, # Spesifiserer hva valideringsdataene er
control = contrl_preds # Legger valgene som jeg lagret over
)
6.0.4.2 Random forest
rf_spec <-
rand_forest(trees = 500) %>% # Spesifiserer valg for å prøve å sørge for at modellen er best mulig tilpasset data
set_mode("classification")
rf_wf <- workflow(ted_oppskrift,
rf_spec)
ranger_rs <- fit_resamples(
rf_wf,
resamples = ted_folds,
control = contrl_preds
)
6.0.4.3 SVM
svm_spec <-
svm_rbf() %>%
set_engine("kernlab", scaled = FALSE) %>% # Valg for å tune modellen
set_mode("classification")
svm_wf <- workflow(ted_oppskrift,
svm_spec)
svm_rs <- fit_resamples(
svm_wf,
resamples = ted_folds,
control = contrl_preds,
)
6.0.5 Vurder hvor gode modellene er
Det finnes flere måter å vurdere hvor godt en modell har gjort det, og hvilket mål man bruker avhenger gjerne av hva man er mest opptatt av. “Accuracy” er et mål som gir oss overordnet treffsikkerhet på modellen. Den spør: “Hvor mange av alle observasjonene, klarte modellen å klassifisere riktig?” Men vi kunne også vært spesielt interessert i antall sanne positive, falske positive, også videre. Modellen kommer nemlig aldri til å kunne klassifisere alle observasjonene riktig, og vi må gjøre en vurdering på hvor vidt vi ønsker en skjevhet i den ene eller andre retningen. Tenk deg for eksempel at vi har en modell som måler risikoen for å begå en kriminell handling etter å ha vært i fengsel - er det da viktigst at modellen predikerer de som begår kriminalitet feil, eller de som ikke begår kriminalitet?
- Accuracy: Hvor mange observasjoner klarte modellen å klassifisere riktig?
- Recall (sensitivity): Hvor godt modellen klassifiserer sanne positive.
- Precision: Hvor mange positive utfall som var sanne positive.
- Specificity: Hvor mange negative utfall som var sanne negative.
- F1: En vektet form for “accuracy”, som gir høyere vekt til falske negative og positive.
collect_metrics(glm_rs) # Accuracy til den logistiske modellen er på 95 prosent. Den klassifiserer 95 prosent av observasjonene riktig.
## # A tibble: 2 × 6
## .metric .estimator mean n std_err .config
## <chr> <chr> <dbl> <int> <dbl> <chr>
## 1 accuracy binary 0.950 5 0.00322 Preprocessor1_Model1
## 2 roc_auc binary 0.877 5 0.0151 Preprocessor1_Model1
collect_metrics(ranger_rs) # Random forest klassifiserer 95,2 prosent av observasjonene riktig
## # A tibble: 2 × 6
## .metric .estimator mean n std_err .config
## <chr> <chr> <dbl> <int> <dbl> <chr>
## 1 accuracy binary 0.952 5 0.00174 Preprocessor1_Model1
## 2 roc_auc binary 0.930 5 0.00715 Preprocessor1_Model1
collect_metrics(svm_rs) # SVM klassifiserer 95,9 prosent riktig.
## # A tibble: 2 × 6
## .metric .estimator mean n std_err .config
## <chr> <chr> <dbl> <int> <dbl> <chr>
## 1 accuracy binary 0.959 5 0.00221 Preprocessor1_Model1
## 2 roc_auc binary 0.903 5 0.0119 Preprocessor1_Model1
Forvirringsmatriser (confusion matrix) gir oss en oversikt over hvor mange tekster som ble klassifisert riktig og hvor mange som ble klassifisert feil innenfor hver kategori. Her ser vi noe av problemet med å stole på “accuracy” alene. Random forest latet til å klassifisere flere tekster riktig enn den logistiske modellen, men den klassifiserer også alle tekstene som “annet”.
# Lager forvirringsmatriser for hver av modellene
metrikk_glm <- collect_predictions(glm_rs)
metrikk_glm %>%
conf_mat(truth = tema, estimate = .pred_class) %>%
autoplot(type = "heatmap")
metrikk_rf <- collect_predictions(ranger_rs)
metrikk_rf %>%
conf_mat(truth = tema, estimate = .pred_class) %>%
autoplot(type = "heatmap")
metrikk_svm <- collect_predictions(svm_rs)
metrikk_svm %>%
conf_mat(truth = tema, estimate = .pred_class) %>%
autoplot(type = "heatmap")
Specificity, som fokuserer på sanne og falske negative, gir oss en annen historie enn accuracy. Her gjør random forest det elendig. Fordi den ikke har klassifisert noen observasjoner til å ha tema “politikk”, blir specificity NA. Vår SVM-modell gjør det fortsatt ganske bra, med en precision på 93 prosent - mot den logistiske modellen sine 47 prosent. La oss ta med oss SVM-modellen videre.
# Undersøker recall for hver av modellene
spec(metrikk_rf, metrikk_rf$.pred_class, metrikk_rf$tema)
## # A tibble: 1 × 3
## .metric .estimator .estimate
## <chr> <chr> <dbl>
## 1 spec binary NA
spec(metrikk_glm, metrikk_glm$.pred_class, metrikk_glm$tema)
## # A tibble: 1 × 3
## .metric .estimator .estimate
## <chr> <chr> <dbl>
## 1 spec binary 0.471
spec(metrikk_svm, metrikk_svm$.pred_class, metrikk_svm$tema)
## # A tibble: 1 × 3
## .metric .estimator .estimate
## <chr> <chr> <dbl>
## 1 spec binary 0.931
Til slutt kjører vi den endelige modellen på testdatasettet for å se hvor godt modellen klassifiserer her. Testdatasettet har ikke vært i bruk før nå, vi har holdt det fullstendig utenfor hele analysen. Dette er dermed den siste sjekken av hvor godt modellen klarer å klassifisere TED-talks som den aldri har sett før.
final_fitted <- last_fit(svm_wf, ted_splitt) # Passer SVM-modellen til testdatasettet
collect_metrics(final_fitted) # Sjekker hvor bra modellen gjorde det (accuracy og ROC-kurve)
## # A tibble: 2 × 4
## .metric .estimator .estimate .config
## <chr> <chr> <dbl> <chr>
## 1 accuracy binary 0.952 Preprocessor1_Model1
## 2 roc_auc binary 0.901 Preprocessor1_Model1
6.0.6 Bruk modellen videre
Hvis modellen er god nok, kan vi nå gå videre med å applisere modellen på TED-talksene der vi ikke vet tema. Det er viktig at vi validerer modellen grundig og vurderer om den er god nok, for alle målefeil tar vi med oss videre i analysen. På den positive siden, å bruke veiledet læring for å merke dataene, gjør at vi også kan gi et mål på hvor stor vi kan anta at målefeilen er.
7 Ikke-veiledet læring
Mye data er ikke merket. Det er typisk at vi står foran en stor samling tekster, men vi vet ikke hva tekstene handler om eller hvordan vi skal gruppere dem. I dette tilfellet er ikke-veiledet maskinlæring nyttig.
Det er vanlig å bruke ikke-veiledet maskinlæring for å:
- Gruppere tekster etter hvilke tekster som likner på hverandre (klustring)
- Finne tekster som er spesielt annerledes fra andre tekster (anormalitetsdeteksjon)
- Redusere antallet variabler som vi har med å gjøre (dimensjonsreduksjon)
Word-embeddings, som vi har vært gjennom overfor, er en form for ikke-veiledet læring. Her gjør vi en dimensjonsreduksjon. Et av målene med word embeddings var nemlig å gå fra et høy-dimensjonalt rom der hvert ord blir en variabel, til et lav-dimensjonalt rom der hver ord representeres på et knippe dimensjoner, ofte et sted mellom 50 og 500. Vi skal også gå gjennom temamodeller senere, hvilket er en måte å gruppere (klustre) tekster. Likhetsanalyser, som vi også skal gjennom, er også gjerne ikke-veiledete.
Siden vi skal gjennom såpass mange modeller senere, går vi ikke like grundig inn i materien på ikke-veiledet læring her, som over. Imidlertid er det bare å bla til kapitlene om word-embeddings, likhetsanalyser og temamodellering for å se eksempler på ikke-veiledet læring på tekstdata.
7.0.1 Hierarkisk klustring
Som en innføring, er det under et eksempel på ikke-veiledet læring ved bruk av hierarkisk klustring. Her prøver modellen å organisere tekstene i grupper som hører sammen i et tre.
library(tidytext)
library(quanteda)
library(rainette)
tedtalks_tfidf <- tedtalks %>%
group_by(id) %>%
unnest_tokens(input = transcript,
output = token,
strip_punct = TRUE,
strip_numeric = TRUE,
token = "words") %>%
filter(!token %in% stopwords("en")) %>%
count(token) %>%
bind_tf_idf(token, id, n) %>%
na.omit()
tedtalks_dfm <- tedtalks_tfidf %>%
cast_dfm(id, token, tf_idf) %>%
dfm_trim(min_termfreq = 2, max_termfreq = 1000)
rainette_cluster <- rainette(
tedtalks_dfm,
k = 8)
rainette_plot(rainette_cluster, tedtalks_dfm, k = 6)
8 Ordbøker
9 Tekststatistikk
9.1 Likhet
9.2 Avstand
9.3 Lesbarhet
9.4 Uttrykk
10 Sentiment
10.1 NorSentLex
Det har lenge vært ganske lite ressurser for sentimentanalyse på norsk. Barnes et al. (2019) har ganske nylig satt sammen en stor ordbok med positive og negative ord i for både fullform og lemmatisert form med PoS-tags6. Disse ordbøkene bygger på en en oversatt og manuelt korrigert engelsk korpus av kundetilbakemeldinger (Hu and Liu 2004) og er pakket i både rå .txt-filer og .json-filer. Heldigvis har en tulling også konvertert dette til en pakke i R: NorSentLex (for øyblikket ikke på CRAN). For å laste inn/ned ordbøkene, kan du enten installere R-pakken med devtools::install_github("martigso/NorSentLex") eller bruke det du lærte i skrape-delen av denne notatboken på de originale filene. La oss illustrer med R-pakken:
# devtools::install_github("martigso/NorSentLex")
# library(NorSentLex)
# Ordbøker i fullform
names(nor_fullform_sent)
## [1] "negative" "positive"
# Ordbøker for lemma med PoS-tags
names(nor_lemma_sent)
## [1] "lemma_adj_negative" "lemma_adj_positive" "lemma_noun_negative"
## [4] "lemma_noun_positive" "lemma_padj_negative" "lemma_padj_positive"
## [7] "lemma_verb_negative" "lemma_verb_positive"
Hvis vi vil se på, for eksempel, noen positive ord i fullform, kan vi gå inn i listen nor_fullform_sent og listeelementet som heter $positive:
nor_fullform_sent$positive %>% head()
## [1] "absolutt" "absolutta" "absolutte" "absoluttene" "absolutter"
## [6] "absoluttet"
nor_fullform_sent$positive %>% tail()
## [1] "ønsket" "ønskete" "ønskt" "ønskte"
## [5] "øyeblikkelig" "øyeblikkelige"
nor_fullform_sent$positive %>% sample(., 6)
## [1] "lett" "kjæresten" "sympatisør" "underbart" "tilrå"
## [6] "dufte"
Det er ikke nødvendigvis alt som gir mening som positive og negative ord, med mindre man har i bakhodet at dette er basert på kundeanmeldelser. Så vær varsom!
Om vi videre vil bruke den lemmatiserte ordboken, kan vi også trekke dette ut enkelt fra de forskjellige elementene i nor_lemma_sent. Si at vi skal bruke bare positive substantiv:
nor_lemma_sent$lemma_noun_positive %>% sample(., 6)
## [1] "skarpsinn" "engel" "jubilant" "fortjeneste" "enighet"
## [6] "forsiktighet"
Nå når vi vet hvordan vi finner ordboken, gjenstår å lære hvordan vi bruker den. La oss bruke fullformord fra No.4-albumet data-mappen (no4.rda) som eksempel. Først splitter vi opp teksten i ord (tokens):
library(tidytext)
load("./data/no4.rda")
no4 <- no4 %>%
group_by(titler) %>%
unnest_tokens(ord, tekst)
Så kryss-refererer vi hvert ord med de positive og negative fullformordene i ordboken:
no4$pos_sent <- ifelse(no4$ord %in% nor_fullform_sent$positive, 1, 0)
no4$neg_sent <- ifelse(no4$ord %in% nor_fullform_sent$negative, 1, 0)
table(no4$pos_sent,
no4$neg_sent,
dnn = c("positiv", "negativ"))
## negativ
## positiv 0 1
## 0 2062 117
## 1 217 1
Som vi ser, er det faktisk noen flere negative ord enn positive i albument. Men overvekten av ord er nøytrale (0 på begge). Vi kan også summere opp sentiment over sangene, og se om det er noe forskjell i sentiment mellom dem:
no4_sent <- no4 %>%
group_by(titler) %>%
summarize(pos_sent = mean(pos_sent),
neg_sent = mean(neg_sent)) %>%
mutate(sent = pos_sent - neg_sent)
no4_sent
## # A tibble: 12 × 4
## titler pos_sent neg_sent sent
## <chr> <dbl> <dbl> <dbl>
## 1 Alt vi ikke er 0.100 0.0502 0.0502
## 2 Du trenger ikke å bli stor 0.0537 0.0604 -0.00671
## 3 En av de levende 0.0819 0.0395 0.0424
## 4 Feil sted 0.0374 0.0561 -0.0187
## 5 Hele livet (Ft. Fredrik Høyer) 0.0421 0.0383 0.00383
## 6 Hjemme hos meg 0.0853 0.0155 0.0698
## 7 Hold deg fast 0.147 0.0333 0.113
## 8 Hvilket vi 0.0337 0.0506 -0.0169
## 9 Parentes 0.0563 0.0423 0.0141
## 10 Regndanse i skinnjakke (Ft. Fredrik Høyer) 0.0254 0.00847 0.0169
## 11 Så lenge vi finnes 0.266 0.131 0.135
## 12 Våre beste år 0.115 0.0513 0.0641
Ikke alverden forskjell, men noen sanger er med positive enn negative og motsatt. La oss visualisere:
no4_sent %>%
mutate(neg_sent = neg_sent * -1) %>%
ggplot(., aes(x = str_c(sprintf("%02d", 1:12),
". ",
str_sub(titler, 1, 7),
"[...]"))) +
geom_point(aes(y = neg_sent, color = "Negativ")) +
geom_point(aes(y = pos_sent, color = "Positiv")) +
geom_point(aes(y = sent, color = "Snitt")) +
geom_linerange(aes(ymin = neg_sent, ymax = pos_sent), color = "gray40") +
scale_color_manual(values = c("red", "cyan", "gray70")) +
labs(x = NULL, y = "Sentiment", color = NULL) +
ggdark::dark_theme_minimal() +
theme(axis.text.x = element_text(angle = 90, vjust = .25, hjust = 0))
11 Temamodellering
12 Latente posisjoner
13 Noen tanker om videre læring
14 Oppsummering
Referanser
Vi bruker
readrfordi den virker godt sammen medtidyverseog er noe raskere enn base-funksjonenread.csv()↩︎x <- stopp[[1]]↩︎tidy_books %>% filter(str_detect(word, "kitchen"))↩︎Uttrykket “koding” kan brukes på to forskjellige aktiviteter. På den ene siden snakker vi om å “kode” som å programmere - hvilket er det vi lærer i dette emnet. Å “kode” kan også bety å bygge ut et datasett. Når man ansetter noen til å merke rader i et datasett avhengig av om de faller innenfor en kategori eller ikke, kalles dette også ofte for koding (men en ganske annen form for koding).↩︎
Denne regresjonslikningen er en forenkling. Hvordan sannsynligheten for at et avsnitt handler om rase estimeres, avhenger av hvilken modell vi bruker. Likningen for logistisk regresjon er mer komplisert enn denne, og det samme gjelder SVM, random forest, og andre modeller vi kan bruke for å estimere Y. Intuisjonen er imidlertid den samme - vi bruker tilgjengelig informasjon om ordenes fordeling blant dokumentene for å måle hvor vidt avsnittet handler om rase.↩︎